Using a Timer to Understand React's useEffect hook
React's useEffect
hook is a powerful, controversial part of the React architecture. However, its intended use is often misunderstood. In this article, I will walk through some of the ins and outs of the useEffect
hook with the simple example of a timer component. We will see that a timer isn't necessarily so simple after all.
This post is dedicated to Kesha.
A Simple Countdown
Below is some React code that will set a simple timer counting down from 10 when the component mounts. I've included a cleanup function that simply logs to the console. If we run this code, we will see the log indicating that the effect hook has been run once, on mount. In the UI, we will see the timer countdown to, then past, zero. We will never see the cleanup logging unless we were to unmount the component.
So this is a very simple illustration of an effect hook:
- The hook runs once, because we've included no dependencies in its dependency array.
- The hook will ignore forthcoming re renders due to state changing.
- The hook's cleanup function will only run when we unnmount the component.
import React, { useEffect } from "react";
export default function App() {
const [timer, setTimer] = React.useState(10);
useEffect(() => {
console.log("calling effect");
window.setInterval(() => {
setTimer((time) => time - 1);
}, 1000);
return () => {
console.log("cleaning up effect");
};
}, []);
return (
<div className="App">
<div>Time left : {timer} </div>
</div>
);
}
A Stale Closure
The above example is a bit strange in that it doesn't allow us to stop the countdown when it reaches zero. It's likely that a more realistic implementation would want to allow for such behavior. So let's try adding a simple check such that we don't run the effect if the timer
value is larger than zero.
useEffect(() => {
console.log("calling effect");
window.setInterval(() => {
if (timer > 0) {
console.log(timer);
setTimer((time) => time - 1);
}
}, 1000);
return () => {
console.log("cleaning up effect");
};
}, []);
Running this code will produce some wonky effects. The timer seems to flicker and reset, and our check doesn't seem to be working. Logging the timer
value will reveal that it's eternally stuck at the value of 10
. Why?
The simple bug shown above is a great illustration of the concepts of lexical scoping and closure in Javascript. In Javascript, when a function is created, it remembers it's lexical environment, the various values in the program that it initially had access to. Our bug here is due to the fact that the function within the effect was created when time
equaled 10
, and it doesn't have the ability to read the value as it updates.
In fact, we can pop open the Chrome DevTools and look at the value of timer
within the closure. It will always be 10.
Curiously, our setTimer
function DOES seem to be able to access the correct value of timer
(here aliased as time
). This is because React provides a callback option to setState
hooks specifically to get around this problem of stale closures. The callback will always have access to the most up to date value of whatever state as its argument.
Dependencies Array to the Rescue...?
In order for us to get around this stale closure problem, we can take advantage of the dependency array provided by the useEffect
function. This array will tell React to re-run the effect hook if any value in the array changes. With this strategy, the function will be recreated each time the timer
value changes, capturing the correct and up to date value in the effect's lexical scope.
useEffect(() => {
console.log("calling effect");
window.setInterval(() => {
if (timer > 0) {
console.log(timer);
setTimer((time) => time - 1);
}
}, 1000);
return () => {
console.log("cleaning up effect");
};
}, [timer]);
We can see this by logging the closure's values in DevTools:
Cleaning up and the useEffect hook
A common misunderstanding is that the useEffect
cleanup function only runs on unmount. In fact, this isn't true, as explained in the official React docs
When your component is added to the DOM, React will run your setup function. After every re-render with changed dependencies, React will first run the cleanup function (if you provided it) with the old values, and then run your setup function with the new values.
We can take advantage of this behavior and assign on id
to the setInterval
call for later cleanup. The key here is that the cleanup function runs with a reference to the "old" values. Therefore, our id
is still relevant. This code will clean up the interval after each tick, then set a new one. It's worth pointing out that since the interval only sticks around for one tick, we could just as soon use a setTimeout
here as well.
useEffect(() => {
console.log("calling effect");
const id = window.setInterval(() => {
if (timer > 0) {
console.log(timer);
setTimer((time) => time - 1);
}
}, 1000);
return () => {
console.log("cleaning up effect");
clearInterval(id);
};
}, [timer]);
Preserving a value between renders with useRef
Another strategy for preserving a value between renders is to use the useRef
hook. The object returned by this call contains a current
property that can be read and written and will persist between renders. The intention here is to store persistent values that don't directly affect the UI. A timer's ID is a good use case. In fact, a very similar use case is covered in the official docs
import React, { useEffect, useRef } from "react";
export default function App() {
const [timer, setTimer] = React.useState(10);
let intervalIDRef = useRef();
useEffect(() => {
intervalIDRef.current = window.setInterval(() => {
if (timer > 0) {
console.log(timer);
setTimer((time) => time - 1);
}
}, 1000);
return () => {
console.log("cleaning up effect");
clearInterval(intervalIDRef.current);
};
}, [timer]);