React: stale closure

Aniello Musella - Aug 19 - - Dev Community

In this post, I'll show how to create a closure in a useState hook React app.

I'll not explain what a closure is, because there are many resources about this topic and I don't want to be repetitive. I advise the reading of this article by @imranabdulmalik.

In short, a closure is (from Mozilla):

...the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function's scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.

Just in case you're not familiar with the term lexical environment, you can read this article by @soumyadey or alternatively this one.

The problem

In a React application, you can create accidentally a closure of a variable belonging to the component state created with useState hook. When this happens, you're facing a stale closure problem, that is to say, when you refer to an old value of the state that in the meantime it's changed, and so it's not more relevant.

POC

I've created a Demo React application which the main goal is to increment a counter (belonging to the state) that can be closed in a closure in the callback of setTimeout method.

In short, this app can:

  • Show the value of the counter
  • Increment by 1 the counter
  • Start a timer to increment the counter by 1 after five seconds.
  • Increment by 10 the counter

In the following picture, it's shown the initial UI state of the app, with counter to zero.

Initial state of the application

We'll simulate the closure of the counter in three steps:

  1. Incrementing by 1 the counter

The count has been incremented by 1

  1. Starting the timer to increment by 1 after five seconds

The timer is started

  • Incrementing by 10 before the timeout triggers The count has been incremented by 10

After 5 seconds, the value of the counter is 2.

Final state of the application

The expected value of the counter should be 12, but we get 2.

The reason why this happens it's because we've created a closure of the counter in the callback passed to setTimeout and when the timeout is triggered we set the counter starting from its old value (that was 1).

setTimeout(() => {
        setLogs((l) => [...l, `You closed counter with value: ${counter}\n and now I'll increment by one. Check the state`])
        setTimeoutInProgress(false)
        setStartTimeout(false)
        setCounter(counter + 1)
        setLogs((l) => [...l, `Did you create a closure of counter?`])

      }, timeOutInSeconds * 1000);
Enter fullscreen mode Exit fullscreen mode

Following the full code of the app component.

function App() {
  const [counter, setCounter] = useState<number>(0)
  const timeOutInSeconds: number = 5
  const [startTimeout, setStartTimeout] = useState<boolean>(false)
  const [timeoutInProgress, setTimeoutInProgress] = useState<boolean>(false)
  const [logs, setLogs] = useState<Array<string>>([])

  useEffect(() => {
    if (startTimeout && !timeoutInProgress) {
      setTimeoutInProgress(true)
      setLogs((l) => [...l, `Timeout scheduled in ${timeOutInSeconds} seconds`])
      setTimeout(() => {
        setLogs((l) => [...l, `You closed counter with value: ${counter}\n and now I'll increment by one. Check the state`])
        setTimeoutInProgress(false)
        setStartTimeout(false)
        setCounter(counter + 1)
        setLogs((l) => [...l, `Did you create a closure of counter?`])

      }, timeOutInSeconds * 1000);
    }
  }, [counter, startTimeout, timeoutInProgress])

  function renderLogs(): React.ReactNode {
    const listItems = logs.map((log, index) =>
      <li key={index}>{log}</li>
    );
    return <ul>{listItems}</ul>;
  }

  function updateCounter(value: number) {
    setCounter(value)
    setLogs([...logs, `The value of counter is now ${value}`])
  }

  function reset() {
    setCounter(0)
    setLogs(["reset done!"])
  }

  return (

    <div className="App">
      <h1>Closure demo</h1>
      <hr />
      <h3>Counter value: {counter}</h3><button onClick={reset}>reset</button>
      <hr />
      <h3>Follow the istructions to create a <i>closure</i> of the state variable counter</h3>
      <ol type='1' className=''>
        <li>Set the counter to preferred value <button onClick={() => updateCounter(counter + 1)}>+1</button> </li>
        <li>Start a timeout and wait for {timeOutInSeconds} to increment the counter (current value is {counter}) <button onClick={() => setStartTimeout(true)}>START</button> </li>
        <li>Increment by 10 the counter before the timeout  <button onClick={() => updateCounter(counter + 10)}>+10</button> </li>
      </ol>
      <hr />
      {
        renderLogs()
      }
    </div >
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Solution

The solution is based on the use of useRef hook that lets you reference a value that’s not needed for rendering.

So we add to the App component:

const currentCounter = useRef(counter)
Enter fullscreen mode Exit fullscreen mode

Then we'll modify the callback of setTimeout like shown below:

setTimeout(() => {
        setLogs((l) => [...l, `You closed counter with value: ${currentCounter.current}\n and now I'll increment by one. Check the state`])
        setTimeoutInProgress(false)
        setStartTimeout(false)
        setCounter(currentCounter.current + 1)
        setLogs((l) => [...l, `Did you create a closure of counter?`])

      }, timeOutInSeconds * 1000);
Enter fullscreen mode Exit fullscreen mode

Our callback needs to read the counter value because we log the current value before to increment it.

In case, you don't need to read the value, you can avoid the closure of the counter just using the functional notation to update the counter.

setCounter(c => c + 1)
Enter fullscreen mode Exit fullscreen mode

Credits

. . . . . . . . . . . . . . .
Terabox Video Player