I have created a typewriting effect with React and it works perfectly fine. However, when I change the language with i18n both texts don't have the same length and it keeps writing until both texts have the same length and then it changes the language and starts the effect again.
How can I reset the input when the language has changed? How can I reset the input when the component has been destroyed?
I have recorded a video
I have the same issue when I change from one page to another, as both pages have different texts and they don't have the same length.
Here code of my component
export const ConsoleText = ({text, complete = false}) => {
const [currentText, setCurrentText] = useState("");
const translatedText = i18n.t(text);
const index = useRef(0);
useEffect(() => {
if (!complete && currentText.length !== translatedText.length) {
const timeOut = setTimeout(() => {
setCurrentText((value) => value + translatedText.charAt(index.current));
index.current++;
}, 20);
return () => {
clearTimeout(timeOut);
}
} else {
setCurrentText(translatedText);
}
}, [translatedText, currentText, complete]);
return (
<p className="console-text">
{currentText}
</p>
);
};
You are telling react to do setCurrentText(translatedText) only when it is complete or when the compared text lengths are equal, so yes it continues to write until this moment.
To reset your text when text changes, try creating another useEffect that will reset your states :
useEffect(() => {
index.current = 0;
setCurrentText('');
}, [text]);
Now, I actually did this exact same feature few days ago, here is my component if it can help you :
import React from 'react';
import DOMPurify from 'dompurify';
import './text-writer.scss';
interface ITextWriterState {
writtenText: string,
index: number;
}
const TextWriter = ({ text, speed }: { text: string, speed: number }) => {
const initialState = { writtenText: '', index: 0 };
const sanitizer = DOMPurify.sanitize;
const [state, setState] = React.useState<ITextWriterState>(initialState);
React.useEffect(() => {
if (state.index < text.length - 1) {
const animKey = setInterval(() => {
setState(state => {
if (state.index > text.length - 1) {
clearInterval(animKey);
return { ...state };
}
return {
writtenText: state.writtenText + text[state.index],
index: state.index + 1
};
});
}, speed);
return () => clearInterval(animKey);
}
}, []);
// Reset the state when the text is changed (Language change)
React.useEffect(() => {
if (text.length > 0) {
setState(initialState);
}
}, [text])
return <div className="text-writer-component"><span className="text" dangerouslySetInnerHTML={{ __html: sanitizer(state.writtenText) }} /></div>
}
export default TextWriter;
The translation is made outside of the component so you can pass any kind of text to the component.
Related
What I am trying to do is to update the reset the countdown after changing the status.
There are three status that i am fetching from API .. future, live and expired
If API is returning future with a timestamp, this timestamp is the start_time of the auction, but if the status is live then the timestamp is the end_time of the auction.
So in the following code I am calling api in useEffect to fetch initial data pass to the Countdown and it works, but on 1st complete in handleRenderer i am checking its status and updating the auctionStatus while useEffect is checking the updates to recall API for new timestamp .. so far its working and 2nd timestamp showed up but it is stopped ... means not counting down time for 2nd time.
import React, { useEffect } from 'react';
import { atom, useAtom } from 'jotai';
import { startTimeAtom, auctionStatusAtom } from '../../atoms';
import { toLocalDateTime } from '../../utility';
import Countdown from 'react-countdown';
import { getCurrentAuctionStatus } from '../../services/api';
async function getAuctionStatus() {
let response = await getCurrentAuctionStatus(WpaReactUi.auction_id);
return await response.payload();
}
const Counter = () => {
// component states
const [startTime, setStartTime] = useAtom(startTimeAtom);
const [auctionStatus, setAuctionStatus] = useAtom(auctionStatusAtom);
useEffect(() => {
getAuctionStatus().then((response) => {
setAuctionStatus(response.status);
setStartTime(toLocalDateTime(response.end_time, WpaReactUi.time_zone));
});
}, [auctionStatus]);
//
const handleRenderer = ({ completed, formatted }) => {
if (completed) {
console.log("auction status now is:", auctionStatus);
setTimeout(() => {
if (auctionStatus === 'future') {
getAuctionStatus().then((response) => {
setAuctionStatus(response.status);
});
}
}, 2000)
}
return Object.keys(formatted).map((key) => {
return (
<div key={`${key}`} className={`countDown bordered ${key}-box`}>
<span className={`num item ${key}`}>{formatted[key]}</span>
<span>{key}</span>
</div>
);
});
};
console.log('starttime now:', startTime);
return (
startTime && (
<div className="bidAuctionCounterContainer">
<div className="bidAuctionCounterInner">
<Countdown
key={auctionStatus}
autoStart={true}
id="bidAuctioncounter"
date={startTime}
intervalDelay={0}
precision={3}
renderer={handleRenderer}
/>
</div>
</div>
)
);
};
export default Counter;
You use auctionStatus as a dependency for useEffect.
And when response.status is the same, the auctionStatus doesn't change, so your useEffect won't be called again.
For answering your comment on how to resolve the issue..
I am not sure of your logic but I'll explain by this simple example.
export function App() {
// set state to 'live' by default
const [auctionStatus, setAuctionStatus] = React.useState("live")
React.useEffect(() => {
console.log('hello')
changeState()
}, [auctionStatus])
function changeState() {
// This line won't result in calling your useEffect
// setAuctionStatus("live") // 'hello' will be printed one time only.
// You need to use a state value that won't be similar to the previous one.
setAuctionStatus("inactive") // useEffect will be called and 'hello' will be printed twice.
}
}
You can simply use a flag instead that will keep on changing from true to false like this:
const [flag, setFlag] = React.useState(true)
useEffect(() => {
// ..
}, [flag])
// And in handleRenderer
getAuctionStatus().then((response) => {
setFlag(!flag);
});
Have a look at the following useCountdown hook:
https://codepen.io/AdamMorsi/pen/eYMpxOQ
const DEFAULT_TIME_IN_SECONDS = 60;
const useCountdown = ({ initialCounter, callback }) => {
const _initialCounter = initialCounter ?? DEFAULT_TIME_IN_SECONDS,
[resume, setResume] = useState(0),
[counter, setCounter] = useState(_initialCounter),
initial = useRef(_initialCounter),
intervalRef = useRef(null),
[isPause, setIsPause] = useState(false),
isStopBtnDisabled = counter === 0,
isPauseBtnDisabled = isPause || counter === 0,
isResumeBtnDisabled = !isPause;
const stopCounter = useCallback(() => {
clearInterval(intervalRef.current);
setCounter(0);
setIsPause(false);
}, []);
const startCounter = useCallback(
(seconds = initial.current) => {
intervalRef.current = setInterval(() => {
const newCounter = seconds--;
if (newCounter >= 0) {
setCounter(newCounter);
callback && callback(newCounter);
} else {
stopCounter();
}
}, 1000);
},
[stopCounter]
);
const pauseCounter = () => {
setResume(counter);
setIsPause(true);
clearInterval(intervalRef.current);
};
const resumeCounter = () => {
setResume(0);
setIsPause(false);
};
const resetCounter = useCallback(() => {
if (intervalRef.current) {
stopCounter();
}
setCounter(initial.current);
startCounter(initial.current - 1);
}, [startCounter, stopCounter]);
useEffect(() => {
resetCounter();
}, [resetCounter]);
useEffect(() => {
return () => {
stopCounter();
};
}, [stopCounter]);
return [
counter,
resetCounter,
stopCounter,
pauseCounter,
resumeCounter,
isStopBtnDisabled,
isPauseBtnDisabled,
isResumeBtnDisabled,
];
};
Requirement
I have a requirement to get the editor state in JSON format as well as the text content of the editor. In addition, I want to receive these values in the debounced way.
I wanted to get these values (as debounced) because I wanted to send them to my server.
Dependencies
"react": "^18.2.0",
"lexical": "^0.3.8",
"#lexical/react": "^0.3.8",
You don't need to touch any of Lexical's internals for this; a custom hook that reads and "stashes" the editor state into a ref and sets up a debounced callback (via use-debounce here, but you can use whatever implementation you like) is enough.
getEditorState is in charge of converting the editor state into whichever format you want to send over the wire. It's always called within editorState.read().
function useDebouncedLexicalOnChange<T>(
getEditorState: (editorState: EditorState) => T,
callback: (value: T) => void,
delay: number
) {
const lastPayloadRef = React.useRef<T | null>(null);
const callbackRef = React.useRef<(arg: T) => void | null>(callback);
React.useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const callCallbackWithLastPayload = React.useCallback(() => {
if (lastPayloadRef.current) {
callbackRef.current?.(lastPayloadRef.current);
}
}, []);
const call = useDebouncedCallback(callCallbackWithLastPayload, delay);
const onChange = React.useCallback(
(editorState) => {
editorState.read(() => {
lastPayloadRef.current = getEditorState(editorState);
call();
});
},
[call, getEditorState]
);
return onChange;
}
// ...
const getEditorState = (editorState: EditorState) => ({
text: $getRoot().getTextContent(false),
stateJson: JSON.stringify(editorState)
});
function App() {
const debouncedOnChange = React.useCallback((value) => {
console.log(new Date(), value);
// TODO: send to server
}, []);
const onChange = useDebouncedLexicalOnChange(
getEditorState,
debouncedOnChange,
1000
);
// ...
<OnChangePlugin onChange={onChange} />
}
Code
File: onChangeDebouce.tsx
import {$getRoot} from "lexical";
import { useLexicalComposerContext } from "#lexical/react/LexicalComposerContext";
import React from "react";
const CAN_USE_DOM = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined';
const useLayoutEffectImpl = CAN_USE_DOM ? React.useLayoutEffect : React.useEffect;
var useLayoutEffect = useLayoutEffectImpl;
type onChangeFunction = (editorStateJson: string, editorText: string) => void;
export const OnChangeDebounce: React.FC<{
ignoreInitialChange?: boolean;
ignoreSelectionChange?: boolean;
onChange: onChangeFunction;
wait?: number
}> = ({ ignoreInitialChange= true, ignoreSelectionChange = false, onChange, wait= 167 }) => {
const [editor] = useLexicalComposerContext();
let timerId: NodeJS.Timeout | null = null;
useLayoutEffect(() => {
return editor.registerUpdateListener(({
editorState,
dirtyElements,
dirtyLeaves,
prevEditorState
}) => {
if (ignoreSelectionChange && dirtyElements.size === 0 && dirtyLeaves.size === 0) {
return;
}
if (ignoreInitialChange && prevEditorState.isEmpty()) {
return;
}
if(timerId === null) {
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
})
}, wait);
} else {
clearTimeout(timerId);
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
});
}, wait);
}
});
}, [editor, ignoreInitialChange, ignoreSelectionChange, onChange]);
return null;
}
This is the code for the plugin and it is inspired (or copied) from OnChangePlugin of lexical
Since, lexical is in early development the implementation of OnChangePlugin might change. And in fact, there is one more parameter added as of version 0.3.8. You can check the latest code at github.
The only thing I have added is calling onChange function in timer logic.
ie.
if(timerId === null) {
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
})
}, wait);
} else {
clearTimeout(timerId);
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
});
}, wait);
}
If you are new to lexical, then you have to use declare this plugin as a child of lexical composer, something like this.
File: RichEditor.tsx
<LexicalComposer initialConfig={getRichTextConfig(namespace)}>
<div className="editor-shell lg:m-2" ref={scrollRef}>
<div className="editor-container">
{/* your other plugins */}
<RichTextPlugin
contentEditable={<ContentEditable
className={"ContentEditable__root"} />
}
placeholder={<Placeholder text={placeHolderText} />}
/>
<OnChangeDebounce onChange={onChange} />
</div>
</div>
</LexicalComposer>
In this code, as you can see I have passed the onChange function as a prop and you can also pass wait in milliseconds like this.
<OnChangeDebounce onChange={onChange} wait={1000}/>
Now the last bit is the implementation of onChange function, which is pretty straightforward
const onChange = (editorStateJson:string, editorText:string) => {
console.log("editorStateJson:", editorStateJson);
console.log("editorText:", editorText);
// send data to a server or to your data store (eg. redux)
};
Finally
Thanks to Meta and the lexical team for open sourcing this library. And lastly, the code I have provided works for me, I am no expert, feel free to comment to suggest an improvement.
I am trying to implement a simple file upload drop zone in React:
import { useState, useEffect } from 'react';
import './App.css';
const App = () => {
const [isDropzoneActive, setIsDropzoneActive] = useState(false);
const [files, setFiles] = useState([]);
const [currentChunkIndex, setCurrentChunkIndex] = useState(null);
const handleDragOver = e => {
e.preventDefault();
setIsDropzoneActive(true);
};
const handleDragLeave = e => {
e.preventDefault();
setIsDropzoneActive(false);
};
// Update the files array
const handleDrop = e => {
e.preventDefault();
setIsDropzoneActive(false);
// Just overwrite for this simple example
setFiles(e.dataTransfer.files);
};
// Monitor the files array
useEffect(() => {
if (files.length > 0) {
console.log('got a file');
setCurrentChunkIndex(0);
}
}, [files]);
// Monitor the chunk index
useEffect(() => {
if (currentChunkIndex !== null) {
readAndUploadCurrentChunk();
}
}, [currentChunkIndex]);
const readAndUploadCurrentChunk = () => {
// Implement later
};
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={"dropzone" + (isDropzoneActive ? " active" : "")}
>
{files.length > 0 ? 'Uploading' : 'Drop your files here'}
</div>
);
}
export default App;
However it seems that the effect that monitors [currentChunkIndex] is not being called correctly. I have attempted to drag files into the drop zone, one by one. [files] effect it called correctly each time but the effect on [currentChunkIndex] doesn't get called. What am I doing wrong here?
currentChunkIndex changes from null to 0, you set it only to 0.
useEffect(() => {
if (files.length > 0) {
console.log('got a file');
setCurrentChunkIndex(files.length);
}
}, [files]);
const AnimatedText = Animated.createAnimatedComponent(Text);
function Component({ texts }) {
const [visitIndex, setVisitIndex] = React.useState(0);
// can't create an array of shared value for each text
// since useSharedValue is a hook, and that throws a warning
const textScalesShared = texts.map((_) => useSharedValue(1));
// can't create an array of animated style for each text
// since useAnimatedStyle is a hook, and that throws a warning
const animatedTextStyle = textScalesShared.map((shared) =>
useAnimatedStyle(() => ({
transform: [{ scale: shared.value }],
}))
);
useEffect(() => {
// code to reduce text scale one after another
// it will loop over the array of textScaleShared values
// passed to each component and update it
if (visitIndex === texts.length) {
return;
}
textScalesShared[visitIndex].value = withDelay(
1000,
withTiming(0.5, {
duration: 1000,
})
);
const timerId = setTimeout(() => {
setVisitIndex((idx) => idx + 1);
}, 1000);
return () => {
clearTimeout(timerId);
};
}, [visitIndex]);
return texts.map((text, index) => {
if (index <= visitIndex) {
return (
<AnimatedRevealingText
key={index}
fontSize={fontSize}
revealDuration={revealDuration}
style={animatedStylesShared[index]}
{...props}
>
{text}
</AnimatedRevealingText>
);
} else {
return null;
}
});
}
I want to apply animated styles to an array of components, but since useSharedValue and useAnimatedStyle are both hooks, I am unable to loop over the prop and create a shared value and the corresponding style for each of the component.
How can I achieve the same?
EDIT: updated to add the full code.
You can create a component to handle the useSharedValue and useAnimatedStyle hooks for every item using the visitIndex value:
AnimatedTextItem.js
const AnimatedText = Animated.createAnimatedComponent(Text);
const AnimatedTextItem = ({text, visited}) => {
const textScaleShared = useSharedValue(1);
const style = useAnimatedStyle(() => ({
transform: [{ textScaleShared.value }],
}));
useEffect(()=> {
if(visited) {
textScaleShared.value = withDelay(
1000,
withTiming(0.5, {
duration: 1000,
});
);
}
}, [visited]);
return (<AnimatedText style={style}>{text}</AnimatedText>)
}
Component.js
function Component({texts}) {
const [visitIndex, setVisitIndex] = React.useState(0);
useEffect(() => {
// code to reduce text scale one after another
// it will loop over the array of textScaleShared values
// passed to each component and update it
if (visitIndex === texts.length) {
return;
}
const timerId = setTimeout(() => {
setVisitIndex((idx) => idx + 1);
}, revealDuration);
return () => {
clearTimeout(timerId);
};
}, []);
return texts.map((text, index) => (<AnimatedTextItem text={text} visited={visitIndex === index}/>))
}
You can compose a component to handle it for you, but you need to pass the index of the text you're mapping through.
Like this
const AnimatedText = ({styleIndex}) => {
const textScaleShared = useSharedValue(styleIndex + 1);
const animatedTextStyle = useAnimatedStyle(() => ({
transform: [{ scale: textScaleShared.value }],
}));
const Animated = Animated.createAnimatedComponent(Text);
return <Animated style={animatedTextStyle}>{text}</Animated>;
};
function Component({ texts }) {
useEffect(() => {
// code to reduce text scale one after another
}, []);
return texts.map((text, index) => (
<AnimatedText key={index} styleIndex={index}>
{text}
</AnimatedText>
));
}
Interesting problem :) Let me see if i can come up a solution.
You already notice hook can't be in a dynamic array since the length of array is unknown.
Multiple components
You can have as many as components as you want, each one can have a hook, ex.
const Text = ({ text }) => {
// useSharedValue(1)
// useAnimatedStyle
}
const Components = ({ texts }) => {
return texts.map(text => <Text text={text} />)
}
Single hook
You can also see if you can find a className that can apply to all components at the same time. It's css i assume.
i've got Tabs component, it has children Tab components. Upon mount it calculates meta data of Tabs and selected Tab. And then sets styles for tab indicator. For some reason function updateIndicatorState triggers several times in useEffect hook every time active tab changes, and it should trigger only once. Can somebody explain me what I'm doing wrong here? If I remove from deps of 2nd useEffect hook function itself and add a value prop as dep. It triggers correctly only once. But as far as I've read docs of react - I should not cheat useEffect dependency array and there are much better solutions to avoid that.
import React, { useRef, useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { defProperty } from 'helpers';
const Tabs = ({ children, value, orientation, onChange }) => {
console.log(value);
const indicatorRef = useRef(null);
const tabsRef = useRef(null);
const childrenWrapperRef = useRef(null);
const valueToIndex = new Map();
const vertical = orientation === 'vertical';
const start = vertical ? 'top' : 'left';
const size = vertical ? 'height' : 'width';
const [mounted, setMounted] = useState(false);
const [indicatorStyle, setIndicatorStyle] = useState({});
const [transition, setTransition] = useState('none');
const getTabsMeta = useCallback(() => {
console.log('getTabsMeta');
const tabsNode = tabsRef.current;
let tabsMeta;
if (tabsNode) {
const rect = tabsNode.getBoundingClientRect();
tabsMeta = {
clientWidth: tabsNode.clientWidth,
scrollLeft: tabsNode.scrollLeft,
scrollTop: tabsNode.scrollTop,
scrollWidth: tabsNode.scrollWidth,
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
};
}
let tabMeta;
if (tabsNode && value !== false) {
const wrapperChildren = childrenWrapperRef.current.children;
if (wrapperChildren.length > 0) {
const tab = wrapperChildren[valueToIndex.get(value)];
tabMeta = tab ? tab.getBoundingClientRect() : null;
}
}
return {
tabsMeta,
tabMeta,
};
}, [value, valueToIndex]);
const updateIndicatorState = useCallback(() => {
console.log('updateIndicatorState');
let _newIndicatorStyle;
const { tabsMeta, tabMeta } = getTabsMeta();
let startValue;
if (tabMeta && tabsMeta) {
if (vertical) {
startValue = tabMeta.top - tabsMeta.top + tabsMeta.scrollTop;
} else {
startValue = tabMeta.left - tabsMeta.left;
}
}
const newIndicatorStyle =
((_newIndicatorStyle = {}),
defProperty(_newIndicatorStyle, start, startValue),
defProperty(_newIndicatorStyle, size, tabMeta ? tabMeta[size] : 0),
_newIndicatorStyle);
if (isNaN(indicatorStyle[start]) || isNaN(indicatorStyle[size])) {
setIndicatorStyle(newIndicatorStyle);
} else {
const dStart = Math.abs(indicatorStyle[start] - newIndicatorStyle[start]);
const dSize = Math.abs(indicatorStyle[size] - newIndicatorStyle[size]);
if (dStart >= 1 || dSize >= 1) {
setIndicatorStyle(newIndicatorStyle);
if (transition === 'none') {
setTransition(`${[start]} 0.3s ease-in-out`);
}
}
}
}, [getTabsMeta, indicatorStyle, size, start, transition, vertical]);
useEffect(() => {
const timeout = setTimeout(() => {
setMounted(true);
}, 350);
return () => {
clearTimeout(timeout);
};
}, []);
useEffect(() => {
if (mounted) {
console.log('1st call mounted');
updateIndicatorState();
}
}, [mounted, updateIndicatorState]);
let childIndex = 0;
const childrenItems = React.Children.map(children, child => {
const childValue = child.props.value === undefined ? childIndex : child.props.value;
valueToIndex.set(childValue, childIndex);
const selected = childValue === value;
childIndex += 1;
return React.cloneElement(child, {
selected,
indicator: selected && !mounted,
value: childValue,
onChange,
});
});
const styles = {
[size]: `${indicatorStyle[size]}px`,
[start]: `${indicatorStyle[start]}px`,
transition,
};
console.log(styles);
return (
<>
{value !== 2 ? (
<div className={`tabs tabs--${orientation}`} ref={tabsRef}>
<span className="tab__indicator-wrapper">
<span className="tab__indicator" ref={indicatorRef} style={styles} />
</span>
<div className="tabs__wrapper" ref={childrenWrapperRef}>
{childrenItems}
</div>
</div>
) : null}
</>
);
};
Tabs.defaultProps = {
orientation: 'horizontal',
};
Tabs.propTypes = {
children: PropTypes.node.isRequired,
value: PropTypes.number.isRequired,
orientation: PropTypes.oneOf(['horizontal', 'vertical']),
onChange: PropTypes.func.isRequired,
};
export default Tabs;
useEffect(() => {
if (mounted) {
console.log('1st call mounted');
updateIndicatorState();
}
}, [mounted, updateIndicatorState]);
This effect will trigger whenever the value of mounted or updateIndicatorState changes.
const updateIndicatorState = useCallback(() => {
...
}, [getTabsMeta, indicatorStyle, size, start, transition, vertical]);
The value of updateIndicatorState will change if any of the values in its dep array change, namely getTabsMeta.
const getTabsMeta = useCallback(() => {
...
}, [value, valueToIndex]);
The value of getTabsMeta will change whenever value or valueToIndex changes. From what I'm gathering from your code, value is the value of the selected tab, and valueToIndex is a Map that is re-defined on every single render of this component. So I would expect the value of getTabsMeta to be redefined on every render as well, which will result in the useEffect containing updateIndicatorState to run on every render.