variable in useState not updating in useEffect callback

Abhay Sehgal picture Abhay Sehgal · Nov 24, 2019 · Viewed 10.5k times · Source

I'm having an issue while using useState and useEffect hooks

import { useState, useEffect } from "react";

const counter = ({ count, speed }) => {
    const [inc, setInc] = useState(0);

    useEffect(() => {

        const counterInterval = setInterval(() => {
            if(inc < count){
                setInc(inc + 1);
            }else{
                clearInterval(counterInterval);
            }
        }, speed);

    }, [count]);

    return inc;
}

export default counter;

Above code is a counter component, it takes count in props, then initializes inc with 0 and increments it till it becomes equal to count

The issue is I'm not getting the updated value of inc in useEffect's and setInterval's callback every time I'm getting 0, so it renders inc as 1 and setInterval never get clear. I think inc must be in closure of use useEffect's and setInterval's callback so I must get the update inc there, So maybe it's a bug?

I can't pass inc in dependency ( which is suggested in other similar questions ) because in my case, I've setInterval in useEffect so passing inc in dependency array is causing an infinite loop

I have a working solution using a stateful component, but I want to achieve this using functional component

Answer

helloitsjoe picture helloitsjoe · Nov 24, 2019

There are a couple of issues:

  1. You're not returning a function from useEffect to clear the interval
  2. Your inc value is out of sync because you're not using the previous value of inc.

One option:

const counter = ({ count, speed }) => {
    const [inc, setInc] = useState(0);

    useEffect(() => {
        const counterInterval = setInterval(() => {
            setInc(inc => {
                if(inc < count){
                    return inc + 1;
                }else{
                    // Make sure to clear the interval in the else case, or 
                    // it will keep running (even though you don't see it)
                    clearInterval(counterInterval);
                    return inc;
                }
            });
        }, speed);

        // Clear the interval every time `useEffect` runs
        return () => clearInterval(counterInterval);

    }, [count, speed]);

    return inc;
}

Another option is to include inc in the deps array, this makes things simpler since you don't need to use the previous inc inside setInc:

const counter = ({ count, speed }) => {
    const [inc, setInc] = useState(0);

    useEffect(() => {
        const counterInterval = setInterval(() => {
            if(inc < count){
                return setInc(inc + 1);
            }else{
                // Make sure to clear your interval in the else case,
                // or it will keep running (even though you don't see it)
                clearInterval(counterInterval);
            }
        }, speed);

        // Clear the interval every time `useEffect` runs
        return () => clearInterval(counterInterval);

    }, [count, speed, inc]);

    return inc;
}

There's even a third way that's even simpler: Include inc in the deps array and if inc >= count, return early before calling setInterval:

    const [inc, setInc] = useState(0);

    useEffect(() => {
        if (inc >= count) return;

        const counterInterval = setInterval(() => {
          setInc(inc + 1);
        }, speed);

        return () => clearInterval(counterInterval);
    }, [count, speed, inc]);

    return inc;