Animations with React: How a simple component can affect your performance
Federico Kauffman
Posted on May 30, 2021
Originally published in Streaver's blog.
Animations on the web
If you are working on a modern app, you will likely use some kind of animations. They might be simple transitions, for which you should probably use a CSS Transition or even if they are more complex transitions/animations, you can use CSS Keyframes. These techniques will cover most cases, but sometimes you will need customization, and JavaScript might be your only choice.
If you are going the JavaScript route (or, in our case React), you must be careful not to compromise your app's performance and always remember that JS runs a single thread for the UI.
What is the easiest way to define an animation?
Generally, the best way to define an animation is with a mathematical function. For this case, I will keep it simple and say that our function will be a function of time:
// Given a time, calculate how everything should look like
// (the something function)
const animation = (time) => {
return something(time);
}
You can define more complex animations and functions, for example, one that depends on the previous animation state or some global state (like a game would do). But we will stay with the simplest case.
As an example we are going to animate an svg
element according to a given mathematical function. Since we are going to move the svg
to an x
and y
position it would make sense that our animation
function returns what the styles of that svg
should look like at a given time
, something like:
const animation = (time) => {
// X_SPEED is a constant that tells the animation
// how many pixes per millisecond x should move.
const x = (X_SPEED * time) % WIDTH;
// A, B, C and D are constants that define the
// behavior of our Sin function.
const y = A * Math.sin(B * (x + C)) + D;
return {
transform: `translateX(${x}px) translateY(${y}px)`,
};
}
This example is almost the same as you do with CSS Keyframes, the only difference is that here you need to provide a function that defines every frame, and with Keyframes, you give the essential parts, and the browser fills in the blanks.
You might be asking yourself:
Why bother writing your animations with JS if CSS Keyframes are easier?
You have to remember that our goal is to understand the performance aspects of animations. I assume you will use this for complex cases only. For everything else, pure CSS is likely the best choice.
Writing a simple animated React component
Our component will be an SVG Circle that we will move on the screen according to a provided animation function. As a first step, we simply render the SVG.
const Animation = ({ animation }) => {
const [animatedStyle, setAnimatedStyle] = useState({});
return (
<svg
viewBox="0 0 100 100"
height="10"
width="10"
style={animatedStyle}
>
<circle cx="50" cy="50" r="50" fill="black" />
</svg>
);
};
Now we can use our Animation
component (which is yet to be animated) as follows:
// WIDTH, HEIGHT, X_SPEED, A, B, C and D are given constants
const SlowAnimations = () => {
return (
<div style={{ width: WIDTH, height: HEIGHT }}>
<Animation
animation={(time) => {
const x = (X_SPEED * time) % WIDTH;
const y = A * Math.sin(B * (x + C)) + D;
return {
transform: `translateX(${x}px) translateY(${y}px)`,
};
}}
/>
</div>
);
};
Now that we have our component on the screen, we need to let the time run and calculate the new styles for the svg
using our animation function. A simple solution could be as follows:
const Animation = ({ animation }) => {
...
useEffect(() => {
let currentTime = 0;
let prevTime = currentTime;
const animateFn = () => {
// We calculate how much time has elapsed from the
// previous run in order to know what styles we need
// to apply at the current time.
const now = performance.now();
const delta = now - prevTime;
prevTime = now;
currentTime = currentTime + delta;
// We set the resulting styles from the animation
// and React renders the new state to the DOM.
setAnimatedStyle(animation(currentTime));
};
/* We assume the animations start at t = 0, this means
* that the initial style can be calculated by running
* the animation at t = 0.
*/
setAnimatedStyle(animation(currentTime));
// To achieve 60 FPS you need to
// animate every 1/60 seconds ~= 16 ms
const intervalId = setInterval(animateFn, 16);
return () => clearInterval(intervalId);
}, [animation]);
return (
...
);
};
The Animation
component works and animates things pretty well on the screen, but it has some big problems!
Firstly, using a setInterval
that runs every 16ms is CPU intensive, and your users will notice it. Also, it does not care about anything else that is happening on your computer or mobile device. It will try to execute every 16ms even if your computer is struggling, the battery is running low, or the browser window is not visible.
Secondly, that component is going through a React render and commit cycle every ~16ms because we use the internal state of React to store the animation; when we set the state, a render and a commit happens, and that is killing the CPU even more.
You can read more about this on What are render phase and commit phase in react dom?
.
Also, if you use the React Dev Tools you can see that the component has a lot of activity. In just a few seconds of profiling, it committed and rendered hundreds of times.
But, since React is so fast and you are probably using a beefy computer, you will not feel any sluggishness on the animation.
You can also record a performance profile on your browser, which for my setup it shows that for every second we are animating, we are using our CPU/GPU ~11% of the time.
Now, let's see how to do it better.
Writing a performant animated React component
We start very similarly to the previous implementation. But you will notice we are not using React's useState
hook, and that is because for this implementation after the animation gets started, we don't care about the state of the component. Our objective is to be as fast and efficient as possible.
const Animation = ({
animation,
style,
...props
}) => {
return (
<svg viewBox="0 0 100 100" height="10" width="10">
<circle cx="50" cy="50" r="50" fill="black" />
</svg>
);
};
We are going to be writing to the DOM outside of React render and commit cycle, React is still going to be useful, because it provides the API for setting up the scene, that is mounting, unmounting the element to/from the DOM and the useEffect
hook to get things started.
The next step is to use the useRef
hook and get a handle to the SVG element after it is mounted so we can do the DOM updating ourselves.
const Animation = ({
animation,
style,
...props
}) => {
const elementRef = useRef(null);
...
return (
<svg
ref={elementRef}
...
>
...
</svg>
);
};
Next, we will use the useEffect
hook to synchronize our component with the DOM state. When the element is mounted, and after we have a reference, we create a animateFn
which takes the time provided by the requestAnimationFrame
function and calculates the next animation state. I am assuming you know what requestAnimationFrame
is. If you don't, please refer to the documentation.
const Animation = ({ animation }) => {
...
useEffect(() => {
if (elementRef.current) {
let time = 0;
let animationFrameId, animationFramePrevTime;
const animateFn = (currentTime: number) => {
/* The time provided by RAF (requestAnimationFrame)
* is a DOMHighResTimeStamp.
*
* But we assume our animation functions
* start at t = 0. Because of this we need
* to skip a frame in order to calculate the time delta
* between each frame and use that value to get the
* next step of our animations.
*
* For more details see:
* - https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
* - https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp
*/
if (animationFramePrevTime !== undefined) {
const delta = currentTime - animationFramePrevTime;
time = time + delta;
/* We are rendering outside the react render loop
* so it is possible that a frame runs after the
* element is unmounted and just before the useEffect
* clear function is called. So we need to
* check that the element still exists.
*/
if (elementRef.current) {
// Get the next position
const { transform } = animation(time);
elementRef.current.style.transform = transform;
}
}
// Save the current RAF time as to use in the next frame
animationFramePrevTime = currentTime;
// This starts the requestAnimationFrame loop
// Save the frameId for future cancellation
animationFrameId = requestAnimationFrame(animateFn);
};
// First call to request animation frame
// Save the frameId for future cancellation
animationFrameId = requestAnimationFrame(animateFn);
// This cancels the last requestAnimationFrame call
return () => cancelAnimationFrame(animationFrameId);
}
}, [animation]);
return (
...
);
};
The previous snippet has two key differences from the first implementation. The first one is that we use requestAnimationFrame
, which allows us to be conscious of the user's machine state. In other words, it lets the browser decide when to run the animation and at what FPS. That will save CPU time, battery and will likely make animations smoother.
The second important part is that instead of using useState
to save the animation and let React handle the rendering, we update the DOM ourselves. And that avoids the React commit and render loop from executing at all, saving CPU time.
If you look at the React Dev Tools, you will notice that this component is only committed and rendered once even though it runs the animation.
By looking at the browser performance profile, the CPU/GPU usage is ~9% for every second of animation. It does not sound like a significant change, but this is just one small component. Imagine doing the same with a real application that has hundreds of components. You can try it yourself at the demo application
Conclusions
As with everything in life, there are tradeoffs. The biggest one for this case, in my opinion, is that the first implementation was simple and easy to read. If you know the basics of React, you could understand it. The second one not so much, you need to understand React and the browser in more depth. Sometimes this is acceptable. On the other hand, the first implementation was very inefficient, the second one is very fast, and that is the most significant tradeoff.
And finally, if you need a framework to decide when to use CSS or JS to animate things, I would start by asking the following questions:
- Does my animation need some kind of state?. If no, then CSS is probably the way to go.
- Do I need control of "every frame"? If the answer is no, then CSS Keyframes is worth trying.
And before you go and animate everything yourself, check out the framer-motion package. It will likely cover most of your needs.
Posted on May 30, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.