9

When I use class component, I have code:

setTimeout(() => console.log(this.state.count), 5000);

When I use hook:

const [count, setCount] = useState(0);
setTimeout(() => console.log(count), 5000);

If I trigger setTimeout then change the count to 1 before the timeout (5000ms), class component will console.log(1) (the newest value), and for useState it is console.log(0) (value when register timeout).
Why does this happen?

Danny
  • 883
  • 8
  • 33

4 Answers4

14

Updated Version:

Question: Difference in behavior of a React State variable inside setTimeout / setInterval for function and class components?

Case 1: State variable in function component (stale closure):

const [value, setValue] = useState(0)

useEffect(() => {
  const id = setInterval(() => {
    // It will always print 0 even after we have changed the state (value)
    // Reason: setInterval will create a closure with initial value i.e. 0
    console.log(value)
  }, 1000)
  return () => {
    clearInterval(id)
  }
}, [])

Case 2: State variable in class component (no stale closure):

constructor(props) {
  super(props)
  this.state = {
    value: 0,
  }
}

componentDidMount() {
  this.id = setInterval(() => {
    // It will always print current value from state
    // Reason: setInterval will not create closure around "this"
    // as "this" is a special object (refernce to instance)
    console.log(this.state.value)
  }, 1000)
}

Case 3: Let's try to create a stale closure around this

// Attempt 1

componentDidMount() {
  const that = this // create a local variable so that setInterval can create closure
  this.id = setInterval(() => {
    console.log(that.state.value)
    // This, too, always print current value from state
    // Reason: setInterval could not create closure around "that"
    // Conclusion: Oh! that is just a reference to this (attempt failed)
  }, 1000)
}

Case 4: Let's again try to create a stale closure in class component

// Attempt 2

componentDidMount() {
  const that = { ...this } // create a local variable so that setInterval can create closure
  this.id = setInterval(() => {
    console.log(that.state.value)
    // Great! This always prints 0 i.e. the initial value from state
    // Reason: setInterval could create closure around "that"
    // Conclusion: It did it because that no longer is a reference to this,
    // it is just a new local variable which setInterval can close around
    // (attempt successful)
  }, 1000)
}

Case 5: Let's again try to create a stale closure in class component

// Attempt 3

componentDidMount() {
  const { value } = this.state // create a local variable so that setInterval can create closure
  this.id = setInterval(() => {
    console.log(value)
    // Great! This always prints 0 i.e. the initial value from state
    // Reason: setInterval created closure around value
    // Conclusion: It is easy! value is just a local variable so it will be closed
    // (attempt successful)
  }, 1000)
}

Case 6: Class has won (no extra effort to avoid the stale closure). But, how to avoid it in function component?

// Let's find solution

const value = useRef(0)

useEffect(() => {
  const id = setInterval(() => {
    // It will always print the latest ref value
    // Reason: We used ref which gives us something like an instance field.
    // Conclusion: So, using ref is a solution
    console.log(value.current)
  }, 1000)
  return () => {
    clearInterval(id)
  }
}, [])

source-1, source-2

Case 6: Let's find another solution for function components

useEffect(() => {
  const id = setInterval(() => {
    // It will always print the latest state value
    // Reason: We used updater form of setState (which provides us latest state value)
    // Conclusion: So, using updater form of setState is a solution
    setValue((prevValue) => {
      console.log(prevValue)
      return prevValue
    })
  }, 1000)
  return () => {
    clearInterval(id)
  }
}, [])

Original Version:

The issue is caused by closures and can be fixed by using ref. But here is a workaround to fix it i.e. access the latest state value using "updater" form of setState:

function App() {

  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    setTimeout(() => console.log('count after 5 secs: ', count, 'Wrong'), 5000)
  }, [])

  React.useEffect(() => {
    setTimeout(() => {
      let count
      setCount(p => { 
        console.log('p: ', p)
        count = p
        return p
       })
      console.log('count after 5 secs: ', count, 'Correct')
    }, 5000);
  }, [])

  return (<div>
    <button onClick={() => setCount(p => p+1)}>Click me before 5 secs</button>
    <div>Latest count: {count}</div>
  </div>)
}

ReactDOM.render(<App />, document.getElementById('mydiv'))
<script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<body>
<div id="mydiv"></div>
</body>
Ajeet Shah
  • 18,551
  • 8
  • 57
  • 87
  • This is an interesting solution. The drawback is that using the setter to get the latest value also causes a rerender, thus the whole setTimeout side effect causes an unnecessary render. This could lead to an infinite loop in some circumstances, e.g. if the useEffect is dependant on the changed count. – deckele Mar 05 '21 at 20:58
  • @deckele `setState(prevValue => prevValue)` will not cause a re-render because the the same value is being returned. React will do : should render : `Object.is(oldValue, newValue)` => `false`. So, there will no re-render if we do `setCount(p => { // do_something; return p})` – Ajeet Shah Mar 05 '21 at 21:09
  • The docs say that there are no additional renders if the result of setState is the same as the previous value. However, this isn't totally correct, the whole function is rerendered a second time, and only then bails out of rendering its children: https://github.com/facebook/react/issues/14994 Still, you are correct in your example that using setState is safe inside of useEffect. But using this method outside in render will cause an infinite loop. Even though current value is identical to the previous one. – deckele Mar 05 '21 at 21:58
  • @deckele OK. Maybe. I didn't read docs when I said this - `there will no re-render if we do setCount(p => { // do_something; return p})`. I tested it locally and then, thought of writing it. I don't know what the truth is. In any case, the examples provided by me are for *understanding* purpose (rarely they will be a real world case). In addition to that, probably we have *less to worry* about *infinite re-rendering* using `setTimeout`. – Ajeet Shah Mar 05 '21 at 22:05
  • @deckele I just saw your edit: `But using this method outside in render will cause an infinite loop. Even though current value is identical to the previous one` : And, I tested it, yes, you are correct. I never knew that because I never wrote code like that but you are correct :) Thanks! – Ajeet Shah Mar 05 '21 at 22:22
  • For anyone who might be as confused as I was when I first saw this React-specifed _updater_ reference - the `useState` "setter" function has [special handling](https://react.dev/reference/react/useState#setstate-parameters) when you pass a function to the setter function. – sherrellbc Aug 13 '23 at 18:25
11

For the useState, it create a timeout using count in the first time. It accesses the count value through a closure. When we set a new value by setCount, the component rerender but does not change the value passed to timeout.
We can use const count = useRef(0) and pass to timeout count.current. This will always use the newest value of count.
Check this link for more information.

Danny
  • 883
  • 8
  • 33
3

Timeouts don't play along nicely with reacts declarative programming model. In functional components, each render is a single frame in time. They never change. When state updates, all state variables are created locally anew and don't overwrite the old closed variables.

You can also think of effects in the same way, where an effect will run in its local realm with all its local state variables on each render and new renders don't affect their output.

The only way to break out of this model is refs. Or class components where state is effectively similar to refs where the instance (this) is the ref container. Refs allow cross-render communication and closure busting. Use sparingly and with caution.

Dan Abramov has a fantastic article explaining all this and a hook that solves this. As you correctly answered, the issue is caused by stale closures. The solution indeed involves using refs.

Mordechai
  • 15,437
  • 2
  • 41
  • 82
2

Explanation

With function components, every render is a function call, creating a new function closure for that specific call. The function component is closing over the setTimeout callback function, so that everything in the setTimeout callback is accessing only the specific render where it was called.

Reusable solution:

Using a Ref and accessing it only within the setTimeout callback will give you a value that is persistent across renders.

However, it isn't that convenient to use a React Ref with a value that is always updating, like a counter. You are in charge of both updating the value, and causing a rerender yourself. Updating a Ref doesn't entail a component render.

My solution, for easy use, is to combine both useState and useRef hooks into a single "useStateAndRef" hook. This way, you get a setter that gets both the value, and a ref for use in async situations such as setTimeout and setInterval:

import { useState, useRef } from "react";

function useStateAndRef(initial) {
  const [value, setValue] = useState(initial);
  const valueRef = useRef(value);
  valueRef.current = value;
  return [value, setValue, valueRef];
}

export default function App() {
  const [count, setCount, countRef] = useStateAndRef(0);
  function logCountAsync() {
    setTimeout(() => {
      const currentCount = countRef.current;
      console.log(`count: ${count}, currentCount: ${currentCount}`);
    }, 2000);
  }
  return (
    <div className="App">
      <h1>useState with updated value</h1>
      <h2>count: {count}</h2>
      <button onClick={() => setCount(prev => prev + 1)}>+</button>
      <button onClick={logCountAsync}>log count async</button>
    </div>
  );
}

Working CodeSandbox link: https://codesandbox.io/s/set-timeout-with-hooks-fdngm?file=/src/App.tsx

deckele
  • 4,623
  • 1
  • 19
  • 25