3

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).

paralaks
  • 183
  • 2
  • 7
  • 1
    "[...] because we’ve created a closure with the value of count set to 0 as it was when the effect callback ran" pretty much sums it up, I think. – Dave Newton Sep 03 '20 at 17:18
  • 1
    `setCount(count => count + 1)` – Emile Bergeron Sep 03 '20 at 17:19
  • Change `setCount(count + 1);` to `setCount(count => count + 1)` – sidthesloth Sep 03 '20 at 17:39
  • 1
    To be clear, I am not looking for a solution. The one I have provided in the question also creates a closure, count is set to 0 as before. The only difference is count's value which should always be 0 when effect runs, is assigned to variable c and increased inside setCount and it works fine. – paralaks Sep 03 '20 at 18:03

2 Answers2

10

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.

Emile Bergeron
  • 17,074
  • 5
  • 83
  • 129
  • Ok, I thought I am getting the idea but I am still confused. I will go over your response and closures to understand what is going on. – paralaks Sep 03 '20 at 18:49
  • 1
    @paralaks I am also confused from time to time, and the best thing to do to help clear the confusion is to test our assumptions with different implementations and look at the results. Exploring the language documentation, tutorials and the most common questions will also help a lot. – Emile Bergeron Sep 03 '20 at 18:54
-2

useRef makes it easy

function Counter() {
  const countRef = useRef(0);

  useEffect(() => {
    const id = setInterval(() => {
      countRef.current++;
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{countRef.current}</h1>;
}
Dan Cancro
  • 1,401
  • 5
  • 24
  • 53