1

I'm trying to understand the general advice I've seen regarding React and stale closures.

Specifically, as I understand it, the term "stale closure" is used to describe a scenario where a component and useEffect function are constructed like this

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

  useEffect(function() {
    setInterval(function log() {
      console.log(`Count is: ${count}`);
    }, 2000);
  }, []);

  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1) }>
        Increase
      </button>
    </div>
  );
}

React calls WatchCount to render a component, and sets the value of the count variable. When javascript calls the log function two seconds later, the count variable will be bound to the count variable from when WatchCount was first called. The value of count won't reflect updates that may have happened on renders of WatchCount that happened in-between the first render and the interval code eventually firing.

The general advice that's given to "solve" this is to list your variable in the dependencies array -- the second argument to useEffect

useEffect(function iWillBeStale() {
  setInterval(function log() {
    console.log(`Count is: ${count}`);
  }, 2000);
}, [count]);

As a javascript programmer, I don't understand how this "solves" the problem. All we've done here is create an array that includes the variable in it, and passed that array to useEffect My naive view is that the count variable in log is still scoped to the first call of WatchCount, and should still be stale.

Am I missing some nuance of javascript's scope here?

Or does this "fix" things because of something that useEffect is doing with those variables?

Or some third thing?

jonrsharpe
  • 115,751
  • 26
  • 228
  • 437
Alana Storm
  • 164,128
  • 91
  • 395
  • 599
  • 1
    "*All we've done here is create an array that includes the variable in it, and passed that array to `useEffect`*" - do you understand what `useEffect` uses this array for? But yes, you're right, it doesn't affect the closure, the closed-over constant keeps its value. – Bergi Apr 15 '21 at 23:04
  • 2
    Notice that react runs the entire render function each time the state changes, which creates a new closure and passes it to `useEffect` again. And notice that the second snippet in your answer is not the complete solution as it was found in the article you linked. – Bergi Apr 15 '21 at 23:05
  • Ah, I think I see now @bergi, thank you. I wasn't paying close attention to the code, I was only paying attention to the words. :) Looking at the code in the solution I now see that the `useEffect` function is returning a function. It's my understanding a function returned from `useEffect` is a cleanup function that runs when the component unmounts, and before running the effects from the previous render. This cleanup function calls `clearInterval`, which means the log function with the stale closure will never run. Happy to "accept as best" if some person wants to answer officially. – Alana Storm Apr 15 '21 at 23:33

1 Answers1

1

Am I missing some nuance of javascript's scope here?

No, you're right, creating the array and passing it to useEffect doesn't affect the closure, the closed-over constant keeps its value.

Or does this "fix" things because of something that useEffect is doing with those variables?

Yes. React runs the entire render function each time the state changes, which creates a new closure and passes it to useEffect again. When the dependencies change, useEffect re-runs the effect function which creates a new interval with the new closure.

Also, the effect function is returning a cleanup function in the author's solution, which runs when the component unmounts or before running the effect the next time (when the dependencies change). This cleanup function calls clearInterval, which means the stale closure won't be executed again, and the number of concurrently active intervals doesn't increase.

Admittedly, this proposed solution has a huge bug: clearing the interval and starting a new interval every time the count changes does not lead to a nice periodic 2s interval, the gaps between two logs might be much larger - the logging is essentially debounced and will only run if no increment happened in the last 2s. If this is not desired, a ref might be a much simpler solution:

const [count, setCount] = useState(0);
const countRef = useRef(0);
countRef.current = count;

useEffect(function() {
  setInterval(function log() {
    console.log(`Count is: ${countRef.current}`);
  }, 2000);
}, []);
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • then, the old `count` value disappeared of interval thanks to the new closure that captures the new `count` , right? –  Jan 27 '23 at 16:06
  • 2
    @Daniel It doesn't disappear, the old closure still closes over the old `count`. It has nothing to do with the new closure being created. It's just that React does stop the old effect (via the cleanup function that calls `clearInterval`), so the old closure with the old value is no longer used. – Bergi Jan 27 '23 at 19:27
  • great, now I understand @Bergi, I have a doubt when the closure is created. The `log()` function creates the closure until useEffect is executed, and the callback passed to useEffect creates the closure as soon as the function component is executed, right? –  Jan 27 '23 at 19:38
  • 1
    i.e. `log()` is executed until the callback passed to useEffect is executed. –  Jan 27 '23 at 19:40
  • 2
    The closure is created when the function is defined, i.e. when the function expression is evaluated. – Bergi Jan 27 '23 at 19:42
  • It does not matter when (in this case `log()` is executed at a future time) the function is defined (evaluated) because the "capture of its variables" is always "evaluated" by the lexical scope, ¿right? thanks @Bergi –  Jan 27 '23 at 19:47
  • 1
    @Daniel true that – Bergi Jan 27 '23 at 19:58