HTML / CSS

Understanding Why React useState Doesn't Update in Window Events

This bug bites everyone at least once. You register a window event listener inside useEffect. Inside that listener, you read state. The state value never ...

13 Apr 2024

Understanding Why React useState Doesn't Update in Window Events

This bug bites everyone at least once. You register a window event listener inside useEffect. Inside that listener, you read state. The state value never changes. It's always the initial value, no matter how many times you update it.

The problem is closures. When you register an event handler in useEffect with an empty dependency array, that function captures the state value from the first render. It never sees future updates.

Here are three ways to fix it.

1. Re-register the handler when state changes

Include the state variable in the useEffect dependency array. The handler gets recreated on every change, always capturing the current value.

Text
const MyComponent = () => {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const handleKeyDown = () => {
      console.log('Current count:', count) // always up-to-date
      setCount(prev => prev + 1)
    }

    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [count])

  return <p>{count}</p>
}

This works, but it's wasteful. You're removing and re-adding the listener on every state change. For events that fire rarely (like resize), that's fine. For events that fire constantly (like scroll), it adds unnecessary overhead.

2. Use a ref to track the latest value

Store the state value in a ref. The event handler reads from the ref, which always points to the latest value.

Text
const MyComponent = () => {
  const [count, setCount] = useState(0)
  const countRef = useRef(count)

  useEffect(() => {
    countRef.current = count
  }, [count])

  useEffect(() => {
    const handleKeyDown = () => {
      console.log('Current count:', countRef.current)
      setCount(prev => prev + 1)
    }

    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [])

  return <p>{count}</p>
}

The listener is registered once. The ref is updated on every render. The handler always reads the latest value through countRef.current. This is my preferred approach for most cases.

3. Use the functional updater form

If you only need the current state value to compute the next state, use the callback form of setState. You don't need to read state at all.

Text
const handleKeyDown = () => {
  setCount(prev => prev + 1) // prev is always the latest value
}

This is the cleanest solution when you're just updating state based on its previous value. But it doesn't help if you need to read the current state for something other than computing the next state (like logging or conditional logic).

Which to use

Use the functional updater when you're computing the next state from the previous state. Use a ref when you need to read (not just update) the current value inside a long-lived listener. Use re-registration as a last resort, or when the handler logic depends on multiple state values and you want the dependency array to track them explicitly.