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.
Related Articles
For related React debugging, check out how to debug React hooks and how to fix memory leaks in React.



