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 examplesetCounter(counter + 1)
) inside thesetInterval
callback instead of passing inx => x + 1
then the closure would actually hold onto the variablecounter
at the time of its creation, and not get updated with the new value untilspeed
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>
)
}
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>
)
}
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>
)
}
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>
)
}
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.