How to fix stale closures in React hooks

Stale closures occur when a function captures old values from its scope and doesn’t see updated values, commonly happening in React hooks with callbacks and effects. As the creator of CoreUI with 12 years of React development experience, I’ve debugged hundreds of stale closure issues in production applications, helping teams understand why their event handlers access outdated state.

The most reliable solution uses the latest React patterns: useRef for mutable values and dependency arrays for effects.

Common Stale Closure Problem

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

  useEffect(() => {
    const interval = setInterval(() => {
      // This always logs 0 (stale closure)
      console.log(count)
      setCount(count + 1) // Always sets to 1
    }, 1000)

    return () => clearInterval(interval)
  }, []) // Empty deps = closure captures initial count (0)

  return <div>Count: {count}</div>
}

Fix 1: Use Functional Updates

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

  useEffect(() => {
    const interval = setInterval(() => {
      // Use functional update to get current value
      setCount(prevCount => {
        console.log(prevCount) // Always current
        return prevCount + 1
      })
    }, 1000)

    return () => clearInterval(interval)
  }, []) // Can keep empty deps

  return <div>Count: {count}</div>
}

Fix 2: Add Dependencies

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

  useEffect(() => {
    const interval = setInterval(() => {
      console.log(count) // Always current
      setCount(count + 1)
    }, 1000)

    return () => clearInterval(interval)
  }, [count]) // Re-create interval when count changes

  return <div>Count: {count}</div>
}

Fix 3: Use useRef for Mutable Values

function Counter() {
  const [count, setCount] = useState(0)
  const countRef = useRef(count)

  // Keep ref in sync
  useEffect(() => {
    countRef.current = count
  }, [count])

  useEffect(() => {
    const interval = setInterval(() => {
      console.log(countRef.current) // Always current
      setCount(countRef.current + 1)
    }, 1000)

    return () => clearInterval(interval)
  }, []) // Empty deps OK

  return <div>Count: {count}</div>
}

Event Handlers with Stale State

function SearchComponent() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  // Problem: handleSearch captures initial query
  const handleSearch = () => {
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(setResults)
  }

  useEffect(() => {
    const timer = setTimeout(handleSearch, 500)
    return () => clearTimeout(timer)
  }, []) // Stale closure!

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  )
}

Fix:

function SearchComponent() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  const handleSearch = useCallback(() => {
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(setResults)
  }, [query]) // Re-create when query changes

  useEffect(() => {
    const timer = setTimeout(handleSearch, 500)
    return () => clearTimeout(timer)
  }, [handleSearch]) // Include dependency

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  )
}

WebSocket with Stale State

function ChatComponent() {
  const [messages, setMessages] = useState([])
  const messagesRef = useRef(messages)

  // Keep ref updated
  useEffect(() => {
    messagesRef.current = messages
  }, [messages])

  useEffect(() => {
    const ws = new WebSocket('ws://localhost:3000')

    ws.onmessage = (event) => {
      const newMessage = JSON.parse(event.data)

      // Use ref to get current messages
      setMessages([...messagesRef.current, newMessage])
    }

    return () => ws.close()
  }, []) // Empty deps OK because we use ref

  return (
    <ul>
      {messages.map(msg => (
        <li key={msg.id}>{msg.text}</li>
      ))}
    </ul>
  )
}

Custom Hook for Latest Value

function useLatest(value) {
  const ref = useRef(value)

  useEffect(() => {
    ref.current = value
  }, [value])

  return ref
}

// Usage
function Counter() {
  const [count, setCount] = useState(0)
  const countRef = useLatest(count)

  useEffect(() => {
    const interval = setInterval(() => {
      console.log(countRef.current) // Always current
      setCount(countRef.current + 1)
    }, 1000)

    return () => clearInterval(interval)
  }, [])

  return <div>Count: {count}</div>
}

Async Functions with Stale State

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)

  useEffect(() => {
    let cancelled = false

    async function fetchUser() {
      const response = await fetch(`/api/users/${userId}`)
      const data = await response.json()

      // Check if effect is still active
      if (!cancelled) {
        setUser(data)
      }
    }

    fetchUser()

    return () => {
      cancelled = true
    }
  }, [userId]) // Re-run when userId changes

  return user ? <div>{user.name}</div> : <div>Loading...</div>
}

Best Practice Note

This is how we handle closures in CoreUI React components to avoid stale state bugs. Always use functional updates for state that depends on previous values, include all dependencies in useEffect arrays, and use useRef for values that need to be accessed in callbacks without causing re-renders. Enable ESLint’s exhaustive-deps rule to catch missing dependencies automatically.

For production applications, consider using CoreUI’s React Admin Template which follows these patterns throughout the codebase.

For related React debugging, check out how to debug React hooks and how to fix memory leaks in React.


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