Right way to clean setTimeout() with React Hooks

Let's suppose that we built a component which increments its value after clicking on the start button:

jsx
import React, { useEffect, useState } from 'react';

const Counter = () => {

    const [counter, setCounter] = useState(0);
    
    useEffect( () => {
        
        // On first render, counter will be 0
        // The condition will be false and setTimeout() won't start
        if(counter)
            setTimeout(() => setCounter(counter + 1), 1000);
    
    }, [counter]);
    
    const startCounter = () => setCounter(counter + 1);
    const stopCounter  = () => setCounter(0);
    
    return(
        
        <div className = 'Counter'>
            {counter}
            <button onClick = {startCounter}>Start</button>
            <button onClick = {stopCounter}>Stop</button>
        </div>
    )

}

export default Counter;

Every time the value of counter varies, useEffect Hook will be executed and the value of counter will increment on every second.

The problem

If we click the stop button while the counter is running, we will set the value of counter to zero. But, because a time out was set and we didn't clear it out, the value of counter won't be zero.

This is an example of a sequence:

  • t = -1 sec user clicks on start button
  • t = 0 sec counter = 0, setCounter(1) after 1 second
  • t = 1 sec counter = 1, setCounter(2) after 1 second
  • t = 2 sec counter = 2, setCounter(3) after 1 second
  • t = 2.5 sec user clicks on stop button, setCounter(0)
  • t = 3 sec counter = 3

After clicking the stop button, the counter won't be zero. That's a non-desirable behavior.

The solution

To avoid this behavior, we need to clear the timer, returning the clearTimeout function.

jsx
import React, { useEffect, useState } from 'react';

const Counter = () => {

    const [counter, setCounter] = useState(0);
    
    useEffect( () => {
        
        // On first render, counter will be 0
        // The condition will be false and setTimeout() won't start
        if(counter)
            var timer = setTimeout(() => setCounter(counter + 1), 1000);
            
        return () => clearTimeout(timer);
    
    }, [counter]);
    
    const startCounter = () => setCounter(counter + 1);
    const stopCounter  = () => setCounter(0);
    
    return(
        
        <div className = 'Counter'>
            {counter}
            <button onClick = {startCounter}>Start</button>
            <button onClick = {stopCounter}>Stop</button>
        </div>
    )

}

export default Counter;

So the sequence now will be:

  • t = -1 sec user clicks on start button
  • t = 0 sec counter = 0, setCounter(1) after 1 second
  • t = 1 sec clearTimeout(t = 0), counter = 1, setCounter(2) after 1 second
  • t = 2 sec clearTimeout(t = 1), counter = 2, setCounter(3) after 1 second
  • t = 2.5 sec user clicks on stop button, setCounter(0)
  • t = 3 sec clearTimeout(t = 2), counter = 0

Notice how clearTimeout() is executed just before there is a change in the counter, canceling the previous setTimeout(). Now the component works as expected.

Hi, I'm Erik, an engineer from Barcelona. If you like the post or have any comments, say hi.