4 min read

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 from false to true; this triggers a rerender
  • Effect is run (due to isRunning changed), and since isRunning is true and timeLeft > 0, then a new interval is started (A)
  • The interval runs, setting timeLeft to timeLeft 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 since isRunning is true and timeLeft > 0, then a new interval is started (B)
  • The interval runs, setting timeLeft to timeLeft 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 since isRunning is true and timeLeft > 0, then a new interval is started (C)
  • The interval runs, setting timeLeft to timeLeft 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!