iTranslated by AI
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.
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
Discussion