When you don't need an effect for setInterval in React
Say you want to start a countdown timer when a user presses a "Start button".
How would you build this in React?
One common way I see - and it's even suggested by ChatGPT - is to have a state variable that remembers if the timer is "on" or "off", and to have an effect watch for that, in order to start the timer.
Here's the actual code ChatGPT suggests:
import React, { useState, useEffect } from 'react';
const CountdownTimer = () => {
const [timeLeft, setTimeLeft] = useState(60); // Set initial time (in seconds)
const [isRunning, setIsRunning] = useState(false); // To track if the timer is running
useEffect(() => {
let timer;
if (isRunning && timeLeft > 0) {
timer = setInterval(() => {
setTimeLeft((prevTime) => prevTime - 1);
}, 1000); // Decrease time every second
} else if (timeLeft === 0) {
clearInterval(timer);
setIsRunning(false); // Stop timer when time reaches zero
}
// Clean up the interval when component unmounts or when time reaches zero
return () => clearInterval(timer);
}, [isRunning, timeLeft]);
const handleStart = () => {
setIsRunning(true); // Start the countdown
};
const handleReset = () => {
setIsRunning(false); // Stop the timer
setTimeLeft(60); // Reset the timer back to 60 seconds
};
return (
<div>
<h1>Countdown Timer: {timeLeft}s</h1>
<button onClick={handleStart} disabled={isRunning}>
Start Countdown
</button>
<button onClick={handleReset}>
Reset
</button>
</div>
);
};
export default CountdownTimer;
I think there are several issues with this approach.
First, effects should be used for synchronisation, whereas starting the counter is a user action
The React docs share a few examples on how to decide between using an effect or an event handler.
Intuitively, you could say that event handlers are always triggered “manually”, for example by clicking a button. Effects, on the other hand, are “automatic”: they run and re-run as often as it’s needed to stay synchronized.
In this case, since starting the countdown is trigger by a user action, it can just be handled in the "Start" button click handler. Here's a simple snippet (checkout the end of the article for a full example):
const handleStart = () => {
// Start the countdown
setInterval(() => {
setTimeLeft((prevTime) => prevTime - 1); // Decrease time every second
}, 1000);
};
Second, using effect to start an interval starts a new interval on every rerender
Let's take a look at the effect in the initial example:
useEffect(() => {
let timer;
if (isRunning && timeLeft > 0) {
timer = setInterval(() => {
setTimeLeft((prevTime) => prevTime - 1);
}, 1000); // Decrease time every second
} else if (timeLeft === 0) {
clearInterval(timer);
setIsRunning(false); // Stop timer when time reaches zero
}
// Clean up the interval when component unmounts or when time reaches zero
return () => clearInterval(timer);
}, [isRunning, timeLeft]);
This code correctly starts a new interval when isRunning
goes from false
to true
. This is what we would expect.
But notice that this code also starts a new timer everytime timeLeft
changes!
The flow would be:
- User clicks "Start" ->
isRunning
changes fromfalse
totrue
; this triggers a rerender - Effect is run (due to
isRunning
changed), and sinceisRunning
istrue
andtimeLeft > 0
, then a new interval is started (A) - The interval runs, setting
timeLeft
totimeLeft
minus 1 second (=59 seconds) ; this triggers a rerender- effect cleanup function is called - interval A is cleared
- Effect is run (due to
timeLeft
changed), and sinceisRunning
istrue
andtimeLeft > 0
, then a new interval is started (B) - The interval runs, setting
timeLeft
totimeLeft
minus 1 second (=58 seconds); this triggers a rerender- effect cleanup function is called - interval B is cleared
- Effect is run (due to
timeLeft
changed), and sinceisRunning
istrue
andtimeLeft > 0
, then a new interval is started (C) - The interval runs, setting
timeLeft
totimeLeft
minus 1 second (=57 seconds); this triggers a rerender - and so on and so forth
So not only is this code creating and clearing an interval on every render, but as Dan Abramov explains in his article on using intervals in React, this can cause hiccups in the actual interval timing:
When we run clearInterval and setInterval, their timing shifts. If we re-render and re-apply effects too often, the interval never gets a chance to fire!
https://overreacted.io/making-setinterval-declarative-with-react-hooks/
So what's a better way to start a countdown? Inside the event handler!
There's no reason why we can't start the interval directly in the event handler.
This is actually the best place to put side effects:
Event handlers are the best place for side effects. Unlike rendering functions, event handlers don't need to be pure, so it's a great place to change something
https://react.dev/learn/responding-to-events
Since we will need to also clear the timer, we need to save the intervalId
. As we don't need the value to trigger a rerender, we can save it in a ref.
Then, we can clear the interval when the user presses "Reset", in the event handler, or when the component unmounts, in a cleanup effect.
const intervalRef = useRef(null);
const handleStart = () => {
// Start the countdown
if (intervalRef.current) {
// if a timer is already running, don't start another one
return;
}
intervalRef.current = setInterval(() => {
// decrease 1 second from the remaining time
setTimeLeft((prevTime) => prevTime - 1);
}, 1000);
};
const handleReset = () => {
// Stop the timer
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setTimeLeft(60); // Reset the timer back to 60 seconds
};
// Cleanup the timer on unmount
useEffect(() => {
return () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, [])
Does this mean you should never use intervals with effect?
Not at all! There are still cases where using an effect is the best choice.
For example, In case the interval needs to run outside of a user action - like for a countdown that starts as soon as page is loaded. For this case, your best bet is probably Dan Abramov's useInterval
hook - https://overreacted.io/making-setinterval-declarative-with-react-hooks/.
How do you work with intervals in React? Share your thoughts in the comments below!
Check out these challenges:
- Create a timer that can be started and stopped
- Build a Typewriter effect component
Member discussion