Next.js starter your AI actually understands. Ship internal tools in days not weeks. Pre-order $199 $499 → [Get it now]

How to fix stale closures in React hooks

Stale closures are one of the most confusing bugs in React hooks — a callback or useEffect captures a variable’s value at the time it was created, then the variable updates but the closure still uses the old value. As the creator of CoreUI with 25 years of front-end development experience, I’ve debugged dozens of stale closure bugs in complex React components and the fix always comes down to dependency arrays and refs. The problem occurs because JavaScript closures close over variables by reference at the time of creation, and React components close over state at each render. Understanding when to add dependencies to arrays and when to use a ref solves the vast majority of stale closure bugs.

The classic stale closure: a timer that reads an old counter value.

import { useState, useEffect } from 'react'

// ❌ Stale closure - always logs 0
function Counter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count) // Always 0! Closure captured count=0
    }, 1000)
    return () => clearInterval(id)
  }, []) // Missing dependency

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

// ✅ Fixed - add count to dependency array
useEffect(() => {
  const id = setInterval(() => {
    console.log(count) // Logs current count
  }, 1000)
  return () => clearInterval(id)
}, [count]) // Re-creates interval when count changes

Adding count to the dependency array re-creates the effect (and the interval) whenever count changes. The new closure captures the updated value.

Use the Functional Update Pattern

Avoid reading state entirely when you only need to update it.

// ❌ Stale closure - count might be outdated
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1) // reads stale count
  }, 1000)
  return () => clearInterval(id)
}, [])

// ✅ Functional update - never reads count, always correct
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1) // receives current value from React
  }, 1000)
  return () => clearInterval(id)
}, []) // Empty array is fine now - we don't read count

The functional form of setState receives the latest state value from React directly, bypassing the closure entirely. This is the cleanest fix when you only need the previous value to compute the next one.

Use a Ref to Access Latest Value Without Re-creating Effects

Store the latest value in a ref to read it inside stable callbacks.

import { useState, useEffect, useRef, useCallback } from 'react'

function SearchList({ query }) {
  const [results, setResults] = useState([])
  const queryRef = useRef(query)

  // Keep ref in sync with latest prop
  useEffect(() => {
    queryRef.current = query
  })

  // Stable callback that always reads the latest query
  const handleClick = useCallback(() => {
    console.log('Current query:', queryRef.current) // Always fresh
  }, []) // Empty array - handler never re-creates

  return <button onClick={handleClick}>Log Query</button>
}

A ref’s .current is always the latest value — refs don’t cause re-renders and don’t get captured by closures. This pattern is useful for event handlers and callbacks that should remain stable (not re-created) but need to read the latest state.

Best Practice Note

This is the same pattern we use in CoreUI React components to handle event listeners that need access to current state without recreating on every render. The rule of thumb: if your effect reads a value but shouldn’t re-run when it changes, store it in a ref. If the effect should re-run, add it to the dependency array. React’s eslint-plugin-react-hooks exhaustive-deps rule catches most stale closure bugs automatically — enable it in your ESLint config.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author

Subscribe to our newsletter
Get early information about new products, product updates and blog posts.

Answers by CoreUI Core Team