Jakub Arnold's Blog


requestAnimationFrame and useEffect vs useLayoutEffect

While trying to implement an animated number counter I stumbled upon an interesting issue with useEffect and requestAnimationFrame not playing together nicely that lead down a rabbit hole of confusion, but lucky for me I wasn’t the first one to stumble upon this, and react-use actually has resolved this exact issue in their useRaf hook. This post is a short explanation of the problem, and why useLayoutEffect fixes it.

useEffect

The useEffect hook is a useful new addition since React 16.8 that allows us to access lifecycle methods in functional components. It can act as componentDidMount, componentDidUpdate and componentWillUnmount while keeping the logic neatly in one place. Let’s quickly go over the few different use cases.

Running code after each render:

function Counter() {
  const [counter, setCounter] = useState(0)

  useEffect(() => {
    document.title = `Counter: ${counter}`
  })

  return (
    <button onClick={() => setCounter(counter + 1)}>
      Clicked {counter} times
    </button>
  )
}

While on some level this acts as componentDidMount and componentDidUpdate, what it really says is queue this function to run on each render. As a quick side note, you might be tempted to write the arrow function without braces like this (it might be more tempting with something else than an assignment, such as a function call):

useEffect(() => (document.title = `Counter: ${counter}`))

but if you try it React will crash with TypeError: destroy is not a function. This might be surprising at first, but the difference is the arrow function is now actually returning the value of the assignment, that is the string Counter: ${counter}. In order for useEffect to also handle cleanup (and be able to replace componentWillUnmount), it has a mechanism for the user to provide a cleanup function. This is actually what we did by accident, because the cleanup function is to be returned from the effect. But this shorter arrow function syntax is equivalent to writing the following:

useEffect(() => {
  return (document.title = `Counter: ${counter}`)
})

Now it should be more clear why react complains. It expects the return value to either be undefined, in which case it doesn’t do any cleanup, or a function which can be called. But we’re returning a string, which is not undefined, and React will crash when it tries to call it.

useEffect cleanup

Our previous example didn’t really have any need for cleanup. We’ll switch to using setInterval to create an auto-incrementing counter where we can also control the speed at which it increments.

Let’s start with a basic structure that is buggy and we’ll incrementally fix it. We’ll add a second state variable speed which will control the timeout parameter of a setInterval. We’ll also add two buttons to control the speed of the timer.

function Counter() {
  const [counter, setCounter] = useState(0)
  const [speed, setSpeed] = useState(1000)

  useEffect(() => {
    setInterval(() => {
      // Similarly to `this.setState` in class components, `setCounter`
      // can accept a function that takes in the current value and
      // returns a new value
      setCounter(x => x + 1)
    }, speed)
  })

  return (
    <p>
      Counter: {counter} ... Speed: {speed}
      <button onClick={() => setSpeed(speed + 100)}>+</button>
      {/* The `Math.max` is here simply so we don't set the speed to `0` */}
      <button onClick={() => setSpeed(Math.max(100, speed - 100))}>-</button>
    </p>
  )
}

If you were to run this code you’ll see the issue very quickly (you can try it here, but be careful, it gets very laggy very quickly). The timer doesn’t increment by 1 every second. It increments by 1 the first second, then by 2, then by 3, then by 4, and so on. This is because by default useEffect will run on every single render, and we’re only ever setting new intervals, we’re not clearing the old ones.

A quick fix would be to return a cleanup function:

useEffect(() => {
  const timerId = setInterval(() => {
    setCounter(x => x + 1)
  }, speed)

  return () => clearInterval(timerId)
})

But this is not ideal as well. Each time the setInterval ticks, it will call setCounter, which in turn causes the component to re-render. But our useEffect also runs on each render, which means the first tick of the timer will cause a re-render which in turn calls useEffect, which clears the first interval, and sets a new one. While the code seemingly does what it’s supposed to, it’s clearly not ideal to clear the interval on each render. We really only need to change it when speed changes. This is why useEffect has a second argument for a list of dependencies. Here’s the complete component:

function Counter() {
  const [counter, setCounter] = useState(0)
  const [speed, setSpeed] = useState(1000)

  useEffect(() => {
    console.log("Setting up a new interval")
    const timerId = setInterval(() => {
      setCounter(x => x + 1)
    }, speed)

    return () => clearInterval(timerId)
  }, [speed])

  return (
    <p>
      Counter: {counter} ... Speed: {speed}
      <button onClick={() => setSpeed(speed + 100)}>+</button>
      {/* The `Math.max` is here simply so we don't set the speed to `0` */}
      <button onClick={() => setSpeed(Math.max(100, speed - 100))}>-</button>
    </p>
  )
}

You can see it in action here.

I’ve also added a console.log to make it clear when the new setInterval is being set.

It is extremely important to make a small note about closures here. If we were to touch counter (for example setCounter(counter + 1)) inside the setInterval callback instead of passing in x => x + 1 then the closure would actually hold onto the variable counter at the time of its creation, and not get updated with the new value until speed changes (at which point the closure is re-created). We could potentially fix this by specifying [counter, speed] as deps, but that would be essentially re-creating the previous case where the interval only ever runs once.

By specifying [speed] as the dependency of our effect we can control when it gets re-run. This is similar to diffing props within componentDidUpdate, but React will do that automatically for us.

If we didn’t want to control the speed, we could simply pass in [] as an empty list of dependencies, which would make the useEffect equivalent to componentDidMount and not be affected by re-renders.

useEffect(() => {
  const timerId = setInterval(() => {
    setCounter(x => x + 1)
  }, speed)

  return () => clearInterval(timerId)
}, [])

But since we care about controlling the speed, we have to leave out this option.

pausing and timing issues

We’ll modify our example a little bit to add a Pause/Resume controls instead of controlling the speed, as this is where I initially ran into the issue with requestAnimationFrame.

function Counter() {
  const [counter, setCounter] = useState(0)
  const [isPaused, setIsPaused] = useState(true)

  useEffect(() => {
    if (!isPaused) {
      const timerId = setInterval(() => {
        setCounter(x => x + 1)
      }, 1)

      return () => clearInterval(timerId)
    }
  }, [isPaused])

  return (
    <p>
      Counter: {counter} ...{" "}
      <button onClick={() => setIsPaused(!isPaused)}>
        {isPaused ? "Resume" : "Pause"}
      </button>
    </p>
  )
}

You can run the code here.

We removed the speed state and instead added an isPaused state variable, which then controls if the timer is being increased.

But setInterval is not the right way to do animations, as it doesn’t synchronize with the browser’s re-painting mechanism. This is where requestAnimationFrame comes in, which basically tells the browser to run the given callback before the next repaint.

Before we switch to it, let us first rewrite the component so that it uses setTimeout instead of setInterval, as that will be nearly identical to the structure of the correct version with requestAnimationFrame.

Interestingly enough, this example already contains the same issue as the final version with requestAnimationFrame, but it is much harder to trigger.

function Counter() {
  const [counter, setCounter] = useState(0)
  const [isPaused, setIsPaused] = useState(true)

  useEffect(() => {
    if (!isPaused) {
      let timerId

      const f = () => {
        setCounter(x => x + 1)
        // Since `f` is only called in a `setTimeout` and not
        // `setInterval`, it needs to re-schedule itself to run
        // again after it finishes.
        timerId = setTimeout(f, 1)
      }

      // The initial run is also scheduled via `setTimeout`
      // to keep this in line with how `requestAnimationFrame`
      // works, and to make the code overall more consistent
      // in the way it executes.
      timerId = setTimeout(f, 1)

      return () => clearTimeout(timerId)
    }
  }, [isPaused])

  return (
    <p>
      Counter: {counter} ...{" "}
      <button onClick={() => setIsPaused(!isPaused)}>
        {isPaused ? "Resume" : "Pause"}
      </button>
    </p>
  )
}

You can try the code here.

A few things changed in this version. We extract our update logic into a separate function f which is the invoked using setTimeout(f, 1) for the first time, and after that it schedules itself to run again as it finishes, using setTimeout(f, 1). This might seem strange, but it is exactly how requestAnimationFrame works.

Now for the final version with requestAnimationFrame, we simply use requestAnimationFrame(f) in place of setTimeout(f, 1), and cancelAnimationFrame in place of clearTimeout. This code tells the browser to run f before it will perform its next repaint, which in most cases will be 60 times per second, giving us a nice and smooth animation.

function Counter() {
  const [counter, setCounter] = useState(0)
  const [isPaused, setIsPaused] = useState(true)

  useEffect(() => {
    if (!isPaused) {
      let timerId

      const f = () => {
        setCounter(x => x + 1)
        timerId = requestAnimationFrame(f)
      }

      timerId = requestAnimationFrame(f)

      return () => cancelAnimationFrame(timerId)
    }
  }, [isPaused])

  return (
    <p>
      Counter: {counter} ...{" "}
      <button onClick={() => setIsPaused(!isPaused)}>
        {isPaused ? "Resume" : "Pause"}
      </button>
    </p>
  )
}

You can try the code here.

Now all it takes is to click the Resume / Pause button really fast, and in a couple of tries you should see the counter will ignore the button and keep increasing despite being paused.

This seems very strange, since we’re telling React to cleanup the request after the button is pressed. The thing is, despite what people say, useEffect is not actually the same as componentDidUpdate. The documentation actually mentions this at one point, but it didn’t occur to me at first what consequences it would have. The problem is the effect passed to useEffect is not run synchronously after the DOM is updated from the render call, but rahter at some point later. This means the browser isn’t blocked by the update logic and the app feels more responsive. Specifically in this case, the browser is able to re-paint before the effect (or its cleanup) is run.

In the case of document.title = ... we didn’t really care if the title was updated a few milliseconds later, but in the case of requestAnimationFrame it does make a difference. The problem is a new animation frame will be requested before the cleanup function of useEffect is called, since the cleanup is not run synchronously. This is essentially a timing issue, where sometimes the browser will re-paint right between our component rendering to the DOM, and the cleanup function being called. This means our f gets a chance to schedule itself again before it is cleaned up, and essentially escapes our cleanup logic.

Lucky for us, there is an easy fix. Apart from useEffect, there is also a useLayoutEffect hook which has exactly the same arguments and works the same way, except it runs synchronously after the DOM is updated. This is exactly what we need, as it will cancel the current animation frame request before a new one has a chance to be queued.

The fixed code is exactly the same, except for useEffect being replaced by useLayoutEffect

function Counter() {
  const [counter, setCounter] = useState(0)
  const [isPaused, setIsPaused] = useState(true)

  useLayoutEffect(() => {
    if (!isPaused) {
      let timerId

      const f = () => {
        setCounter(x => x + 1)
        timerId = requestAnimationFrame(f)
      }

      timerId = requestAnimationFrame(f)

      return () => cancelAnimationFrame(timerId)
    }
  }, [isPaused])

  return (
    <p>
      Counter: {counter} ...{" "}
      <button onClick={() => setIsPaused(!isPaused)}>
        {isPaused ? "Resume" : "Pause"}
      </button>
    </p>
  )
}

You can try the code here

Conclusion and references

This article is a good example on why reading documentation and paying attention to detail is important. When learning about hooks I remember hearing something along the lines of useEffect fires the effect asynchronously later, it didn’t immediately prompt me to ask the question if that could cause any issues, or what are some other use cases for useLayoutEffect. The example I’ve seen people mention with it over and over again is resizing windows or DOM mutations, where useEffect would cause a flicker in the UI and useLayoutEffect wouldn’t. Interestingly, this is the same problem as we’re facing with requestAnimationFrame, as in both cases we want to do something before the browser has a chance to repaint. Only in the case of requestAnimationFrame the repaint does more than a UI flicker, it breaks our code.

References

Related
Javascript · React