DEV Community

loading...
Cover image for Animations with React: How a simple component can affect your performance

Animations with React: How a simple component can affect your performance

Federico Kauffman
CTO at Streaver. Passionate Software Engineer. Host and Co-organizer of the Montevideo JS meetup
・7 min read

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);
}
Enter fullscreen mode Exit fullscreen mode

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)`,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 (
    ...
  );
};
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 (
    ...
  );
};
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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:

  1. Does my animation need some kind of state?. If no, then CSS is probably the way to go.
  2. 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.

Discussion (0)