Expand animation with requestAnimationFrame and React doesn’t work sometimes

Tags: ,



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.

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 refs 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>
  );
};

Here’s a working example.

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.



Source: stackoverflow