I’m trying to implement some single input form with easy “expand” animation to when going to/from edit mode.
Basically I created a ghost element containing value, next to this element is icon button working as edit/save. When you click on the edit button, input with value should appear instead of ghost element and width of input should expand/decrease to constant defined.
I have so far this piece of code, which mostly works fine, but for expand it sometimes doesn’t animate and I don’t know why.
toggleEditMode = () => { const { editMode } = this.state if (editMode) { this.setState( { inputWidth: this.ghostRef.current.clientWidth }, () => { requestAnimationFrame(() => { setTimeout(() => { this.setState({ editMode: false }) }, 150) }) } ) } else { this.setState( { editMode: true, inputWidth: this.ghostRef.current.clientWidth }, () => { requestAnimationFrame(() => { this.setState({ inputWidth: INPUT_WIDTH }) }) } ) } }
You can take a look on example here. Could someone explain what’s wrong or help me to find solution? If I’ll add another setTimeout(() => {...expand requestAnimationFrame here...}, 0)
in code, it starts to work, but I don’t like the code at all.
Advertisement
Answer
This answer explains what’s going on in detail and how to fix it. However, I wouldn’t actually suggest implementing it.
Custom animations are messy, and there are great libraries that handle the dirty work for you. They wrap the ref
s and requestAnimationFrame
code and give you a declarative API instead. I have used react-spring in the past and it has worked very well for me, but Framer Motion looks good as well.
However, if you’d like to understand what’s happening in your example, read on.
What’s happening
requestAnimationFrame
is a way to tell the browser to run some code every time a frame is rendered. One of the guarantees you get with requestAnimationFrame
is that the browser will always wait for your code to complete before the browser renders the next frame, even if this means dropping some frames.
So why doesn’t this seem to work like it should?
Updates triggered by setState
are asynchronous. React doesn’t guarantee a re-render when setState
is called; setState
is merely a request for a re-evaluation of the virtual DOM tree, which React performs asynchronously. This means that setState
can and usually does complete without immediately changing the DOM, and that the actual DOM update may not occur until after the browser renders the next frame.
This also allows React to bundle multiple setState
calls into one re-render, which it sometimes does, so the DOM may not update until the animation is complete.
If you want to guarantee a DOM change in requestAnimationFrame
, you’ll have to perform it yourself using a React ref
:
const App = () => { const divRef = useRef(null); const callbackKeyRef = useRef(-1); // State variable, can be updated using setTarget() const [target, setTarget] = useState(100); const valueRef = useRef(target); // This code is run every time the component is rendered. useEffect(() => { cancelAnimationFrame(callbackKeyRef.current); const update = () => { // Higher is faster const speed = 0.15; // Exponential easing valueRef.current += (target - valueRef.current) * speed; // Update the div in the DOM divRef.current.style.width = `${valueRef.current}px`; // Update the callback key callbackKeyRef.current = requestAnimationFrame(update); }; // Start the animation loop update(); }); return ( <div className="box"> <div className="expand" ref={divRef} onClick={() => setTarget(target === 100 ? 260 : 100)} > {target === 100 ? "Click to expand" : "Click to collapse"} </div> </div> ); };
This code uses hooks, but the same concept works with classes; just replace useEffect
with componentDidUpdate
, useState
with component state, and useRef
with React.createRef
.