Search code examples
javascriptreactjsreact-hooksuse-effectuse-state

React useState does not update value


I am a bit confused as to why this component does not work as expected:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // This effect depends on the `count` state
    }, 1000);
    return () => clearInterval(id);
  }, []); // 🔴 Bug: `count` is not specified as a dependency

  return <h1>{count}</h1>;
}

but rewriting as below works:

function Counter() {
  const [count, setCount] = useState(0);
  let c = count;
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c++);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

React documentation says:

The problem is that inside the setInterval callback, the value of count does not change, because we’ve created a closure with the value of count set to 0 as it was when the effect callback ran. Every second, this callback then calls setCount(0 + 1), so the count never goes above 1.

But the explanation does not make sense. So why the first code does not update count correctly but the second does? (Also declaring as let [count, setCount] = useState(0) then using setCount(count++) works fine too).


Solution

  • Why it looks like it doesn't work?

    There are a couple hints that can help understand what's going on.

    count is const, so it'll never change in its scope. It's confusing because it looks like it's changing when calling setCount, but it never changes, the component is just called again and a new count variable is created.

    When count is used in a callback, the closure captures the variable and count stays available even though the component function is finished executing. Again, it's confusing with useEffect because it looks like the callbacks are created each render cycle, capturing the latest count value, but that's not what's happening.

    For clarity, let's add a suffix to variables each time they're created and see what's happening.

    At mount time

    function Counter() {
      const [count_0, setCount_0] = useState(0);
    
      useEffect(
        // This is defined and will be called after the component is mounted.
        () => {
          const id_0 = setInterval(() => {
            setCount_0(count_0 + 1);
          }, 1000);
          return () => clearInterval(id_0);
        }, 
      []);
    
      return <h1>{count_0}</h1>;
    }
    

    After one second

    function Counter() {
      const [count_1, setCount_1] = useState(0);
    
      useEffect(
        // completely ignored by useEffect since it's a mount 
        // effect, not an update.
        () => {
          const id_0 = setInterval(() => {
            // setInterval still has the old callback in 
            // memory, so it's like it was still using
            // count_0 even though we've created new variables and callbacks.
            setCount_0(count_0 + 1);
          }, 1000);
          return () => clearInterval(id_0);
        }, 
      []);
    
      return <h1>{count_1}</h1>;
    }
    

    Why does it work with let c?

    let makes it possible to reassign to c, which means that when it is captured by our useEffect and setInterval closures, it can still be used as if it existed, but it is still the first one defined.

    At mount time

    function Counter() {
      const [count_0, setCount_0] = useState(0);
    
      let c_0 = count_0;
    
      // c_0 is captured once here
      useEffect(
        // Defined each render, only the first callback 
        // defined is kept and called once.
        () => {
          const id_0 = setInterval(
            // Defined once, called each second.
            () => setCount_0(c_0++), 
            1000
          );
          return () => clearInterval(id_0);
        }, 
        []
      );
    
      return <h1>{count_0}</h1>;
    }
    

    After one second

    function Counter() {
      const [count_1, setCount_1] = useState(0);
    
      let c_1 = count_1;
      // even if c_1 was used in the new callback passed 
      // to useEffect, the whole callback is ignored.
      useEffect(
        // Defined again, but ignored completely by useEffect.
        // In memory, this is the callback that useEffect has:
        () => {
          const id_0 = setInterval(
            // In memory, c_0 is still used and reassign a new value.
            () => setCount_0(c_0++),
            1000
          );
          return () => clearInterval(id_0);
        }, 
        []
      );
    
      return <h1>{count_1}</h1>;
    }
    

    Best practice with hooks

    Since it's easy to get confused with all the callbacks and timing, and to avoid any unexpected side-effects, it's best to use the functional updater state setter argument.

    // ❌ Avoid using the captured count.
    setCount(count + 1)
    
    // ✅ Use the latest state with the updater function.
    setCount(currCount => currCount + 1)
    

    In the code:

    function Counter() {
      const [count, setCount] = useState(0);
    
      useEffect(() => {
        // I chose a different name to make it clear that we're 
        // not using the `count` variable.
        const id = setInterval(() => setCount(currCount => currCount + 1), 1000);
        return () => clearInterval(id);
      }, []);
    
      return <h1>{count}</h1>;
    }
    

    There's a lot more going on, and a lot more explanation of the language needed to best explain exactly how it works and why it works like this, though I kept it focused on your examples to keep it simple.