iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
💬

Handling requestAnimationFrame in React

に公開

Overview

requestAnimationFrame is frequently used for animations. While it is an extremely convenient and easy-to-use method, it has some quirks when used in React. In this article, I will explain how to handle it.

Final Result

First, here is the finished version of the code I will be explaining.

Pressing the START button starts the count, and pressing the STOP button stops it. It's a simple counter, but we'll treat this implementation as the goal and look at the process of getting there.

requestAnimationFrame

First, I'll introduce a simple way to loop using requestAnimationFrame.

const loop = () => {
  // process
  requestAnimationFrame(step);
};
// execute loop
loop();

This is a common pattern. It achieves a loop by recursively executing a function via a callback. To stop the loop, you either stop the recursive calls or use cancelAnimationFrame.

https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame
https://developer.mozilla.org/ja/docs/Web/API/Window/cancelAnimationFrame

Pattern for stopping recursive calls

let count = 0;
const loop = () => {
  // process
  if (count < 200) {
    // only when count is less than 200
    requestAnimationFrame(loop);
  }
  count++;
};
// start loop
loop();

Using cancelAnimationFrame

let reqid;
let count = 0;
const loop = () => {
  reqid = requestAnimationFrame(loop);
  // process
  if (count >= 200) {
    // only when count is less than 200
    cancelAnimationFrame(reqid);
  }
  count++;
};
// start loop
loop();

Using requestAnimationFrame in React

Let's implement it step by step.

Implementing it simply

First, let's try a simple implementation within React.

const Component = () => {
  const loop = () => {
    // process to loop
    requestAnimationFrame(loop);
  };
  React.useEffect(() => {
    loop();
  }, [loop]);

  // ...omitted
};

This looks fine at first glance, but we need to stop the loop when the component is unmounted. Therefore, we use cancelAnimationFrame within the cleanup function of useEffect.

However, to use cancelAnimationFrame, we need the requestID returned by requestAnimationFrame. Let's use useRef to hold the requestID.

const Component = () => {
  const reqIdRef = React.useRef();
  const loop = () => {
    // process to loop
    reqIdRef.current = requestAnimationFrame(loop);
  };
  React.useEffect(() => {
    loop();
    return () => cancelAnimationFrame(reqIdRef.current);
  }, []);

  // ...omitted
};

Perfect.

Considering re-renders

Next, let's try implementing a frame counter.

const Component = () => {
  const reqIdRef = React.useRef();
  let counter = 0;

  const loop = () => {
    reqIdRef.current = requestAnimationFrame(loop);
    counter++;
  };

  React.useEffect(() => {
    loop();
    return () => cancelAnimationFrame(reqIdRef.current);
  }, []);

  return <div>{counter}</div>;
};

If you actually implement the above, the counter will not be updated. The reason is simple: useRef does not trigger a re-render. As a solution, we use useState.

const Component = () => {
  const reqIdRef = React.useRef();
  const [counter, setCounter] = React.useState(0);

  const loop = () => {
    reqIdRef.current = requestAnimationFrame(loop);
    setCounter(pre => ++pre);
  };

  React.useEffect(() => {
    loop();
    return () => cancelAnimationFrame(reqIdRef.current);
  }, []);

  return <div>{counter}</div>;
};

Now the frame counter is implemented.

Extracting into Hooks

Since it's a bit cumbersome as it is, let's try extracting it into a Hook.

// pass the process to be executed in the loop to the callback function
const useAnimationFrame = (callback = () => {}) => {
  const reqIdRef = React.useRef();
  const loop = () => {
    reqIdRef.current = requestAnimationFrame(loop);
    callback();
  };

  React.useEffect(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(reqIdRef.current);
  }, []);
};

const Component = () => {
  const [counter, setCounter] = React.useState(0);

  useAnimationFrame(() => {
    setCounter(prevCount => ++prevCount);
  });

  return (
    <div>
      <div>{counter}</div>
    </div>
  );
};

It's much cleaner now. In terms of functionality, this is complete.

Considering performance

I wouldn't go so far as to call it refactoring, but there are some concerns regarding performance, so I'll try rewriting it.

// pass the process to be executed in the loop to the callback function
const useAnimationFrame = (callback = () => {}) => {
  const reqIdRef = React.useRef();
  // Re-create the function only when the callback function is updated using useCallback
  const loop = React.useCallback(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    callback();
  }, [callback]);

  React.useEffect(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(reqIdRef.current);
    // add loop to the dependency array
  }, [loop]);
};

The overall result looks like this.

const useAnimationFrame = (callback = () => {}) => {
  const reqIdRef = React.useRef();
  const loop = React.useCallback(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    callback();
  }, [callback]);

  React.useEffect(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(reqIdRef.current);
  }, [loop]);
};

const Component = () => {
  const [counter, setCounter] = React.useState(0);

  // Prevent re-creating the function every time setCounter is called
  const countUp = React.useCallback(() => {
    setCounter(prevCount => ++prevCount);
  }, []);
  useAnimationFrame(countUp);

  return (
    <div>
      <div>{counter}</div>
    </div>
  );
};

It looks a bit redundant, but the above prevents unnecessary re-creation of functions.

Creating START and STOP buttons

Now, let's implement the START and STOP buttons like the final version.

// Modified to take a boolean as the first argument
// - true for looping
// - false for stopping
const useAnimationFrame = (isRunning, callback = () => {}) => {
  const reqIdRef = React.useRef();
  const loop = React.useCallback(() => {
    if (isRunning) {
      // loop only when isRunning is true
      reqIdRef.current = requestAnimationFrame(loop);
      callback();
    }
    // add isRunning to the dependency array as well
  }, [isRunning, callback]);

  React.useEffect(() => {
    reqIdRef.current = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(reqIdRef.current);
  }, [loop]);
};

const Component = () => {
  const [counter, setCounter] = React.useState(0);
  const [isRunning, setIsRunning] = React.useState(false);

  const countUp = React.useCallback(() => {
    setCounter(prevCount => ++prevCount);
  }, []);
  useAnimationFrame(isRunning, countUp);

  return (
    <div>
      <div>{counter}</div>
      <button onClick={() => setIsRunning(true)}>START</button>
      <button onClick={() => setIsRunning(false)}>STOP</button>
    </div>
  );
};

I modified the hook to take isRunning as an argument, implementing a loop that executes when true and stops when false. This completes the code I showed at the beginning!

A Bit of Application

Simply counting up is a bit boring, so as an example, I implemented a number roulette animation. You can easily implement a number roulette animation with just a bit of application, so please feel free to refer to it if you're interested in using it.

Summary

This was an explanation of how to handle requestAnimationFrame in React. Due to its nature, it felt like it has quite a few quirks when considering performance. It would be best to use it after considering various aspects to ensure no issues arise.

References

https://css-tricks.com/using-requestanimationframe-with-react-hooks/
https://bom-shibuya.hatenablog.com/entry/2020/10/27/182226

Discussion