I've recently learned about React's inbuilt useEffect
hook and let me tell you - it's neat! ๐ It's commonly used to retrieve data from external APIs and handle timers. This article will walk you through a step-by-step implementation of a timer component via the useEffect
hook.
Let's say our timer component should update each second, like so:
If you're new to React, you might be tempted to define an interval at the top level of your component, as below:
import React, { useState } from "react";
import "./TimerDemo.css";
const TimerDemo = () => {
const [seconds, setSeconds] = useState(0);
setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
return (
<div className="TimerDemo">
<h1>Timer Demo</h1>
<div>โ {seconds} โ</div>
</div>
);
};
export default TimerDemo;
However, the above code will result in the following output.
What's going on here? Has React broken the Universe and altered the rules of spacetime? ๐ฝ Not quite. What's happening here is that multiple intervals are being set over and over in quick succession.
When the component renders for the first time, the interval is set, which changes the state of seconds
each second. Once the state changes, a re-render of the entire component is immediately triggered, and the code inside the component runs once more. Upon running, the code will result in another, identical, interval being set. But the old interval will also continue doing its thing and manipulating the state of seconds
.
So now we have two intervals that are both behaving the same way: updating state each second and triggering the component to re-render. Each re-render will lead to more fresh intervals being set, which in turn will trigger state change. This cycle - a positive feedback loop - will repeat ad infinitum (or more likely ad browser-crashium). ๐
The correct way to handle timers in React is by using the splendiferous useEffect
hook. The top 3 things to know about this hook are:
- it accepts a callback function as the 1st argument
- it accepts an array as its optional 2nd argument
- it returns null, but can optionally be customised to return a function, which is useful for "cleanup" purposes
- it's hard to represent emojitively, because there isn't yet cross-browser support for the hook emoji, so here's an anchor instead โ (okay, this one was a bonus)
We'll examine how each of these points relates to handling timers in React. (Except the emoji one. Which doesn't.)
1. useEffect
Callback Function
The first argument the hook accepts is a callback function. This function is what React understands to be the "effect". In this case, the effect is our interval. Let's define it inside a useEffect
hook.
const TimerDemo = () => {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
});
return (
<div className="TimerDemo">
<h1>Timer Demo</h1>
<div>โ {seconds} โ</div>
</div>
);
};
This will display as the following:
Not at all what we want, our issue remains. This is where the second argument of the useEffect
hook comes into play.
useEffect
Dependency Array
The second argument is an optional array of state or prop values, which specify when the effect should run. We have 3 options here:
- No array: if the array is left out, as in the previous code snippet, the effect will run after every render.
- Empty array: the effect runs once after the initial render.
- Array with state or prop values: the effect runs only when any of these values change.
In our case, if we passed in [seconds]
, we would tell React to re-run the effect each time the state of seconds
changes. Which would, of course, be completely pointless - in fact, this is exactly what we're trying to avoid.
And avoid it we can; specifically, by passing in an empty dependency array, []
. An empty array tells React to only run the effect once, after the component renders for the first time. Let's examine the code below:
const TimerDemo = () => {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
}, []);
return (
<div className="TimerDemo">
<h1>Timer Demo</h1>
<div>โ {seconds} โ</div>
</div>
);
};
Voila! The timer works now.
useEffect Return Function
But what would happen to the interval if we removed the timer component from the DOM? Let's create a scenario where it is removed by wrapping it in another component, Clock, which toggles the timer component. Let's also include a toggle button that removes or adds the timer component to the DOM.
const Clock = () => {
const [isHidden, setIsHidden] = useState(false);
const toggle = () => setIsHidden((hidden) => !hidden);
return (
<div className="Clock">
{!isHidden && <TimerDemo />}
<button class="Clock-btn" onClick={toggle}>
Toggle
</button>
</div>
);
};
At first glance, removing the TimerDemo component from the DOM appears to work as intended. But upon opening the console, an angry sea of red appears. React is not happy. ๐ก
Why is this? Well, when the component is removed, despite disappearing visually, the interval associated with it simply keeps going. There's nothing telling it to stop executing. The interval will proceed to try and update the state of a currently unmounted component, greatly upsetting React in the process. Poor React! ๐ฅบ
So, how do we tell the interval to stop when the component is removed from the DOM? By specifying useEffect
's return value. By default, it returns null, but we can modify this to return a callback function, which will act as "cleanup". Warning: the cleanup function can feel a bit abstract to read about, and the best way to befriend it is to use it and explore its functionality first-hand.
The cleanup is executed at the following times:
- After the initial rendering, the hook only invokes the effect. The cleanup function doesn't run
- On all following re-renderings, the cleanup from the previous effect execution is invoked first, after which the current effect runs
- The cleanup also runs after the component is unmounted, i.e. removed from the DOM
Let's define our cleanup function. In order to stop an interval, we need to capture the interval ID and pass it into a clearInterval
function (a vanilla JS concept). We'll return this function inside our useEffect
and add some print statements to monitor the sequence of execution.
useEffect(() => {
console.log("I am the effect. PARTY! ๐บ ๐ ๐");
const timerId = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
return () => {
console.log("Cleaning up... ๐ ๐งน๐งผ");
clearInterval(timerId);
};
}, []);
In this case, the effect runs when the component is mounted, but never again, due to our 2nd argument, the dependency array, being empty. Therefore, the cleanup function will only run when the component is unmounted, thereby clearing the interval and preventing the error message. The toggle button now works as intended, and React is so happy it can barely contain its excitement.
I hope you are too, after making it through this tutorial! ๐