React Hooks Pitfalls: Stale Closures, Effect Loops, Fetch Races and Useless Memoization
Key takeaways
React hooks are simple to start but full of subtle bugs — stale closures, missing dependencies, memory leaks from missing cleanup. This guide covers the patterns that matter in production React apps.
Why Hooks Are Tricky
Hooks look simple — useState, useEffect — but produce subtle bugs:
- Stale closures capturing old state
- Infinite effect loops
- Memory leaks from missing cleanup
- Expensive computations on every render
This guide covers the patterns that prevent these bugs in production.
Almost all of these bugs come from one fact that is easy to forget: every render is a separate function call with its own variables. When Counter renders with count = 3, every function created during that render (event handlers, effect callbacks, setTimeout callbacks) captures count = 3, permanently. The next render creates a new count and new functions. Nothing is “updated in place”. Class components worked differently, because this.state always pointed to the latest value, so developers coming from classes (or from other frameworks with mutable reactive state) naturally assume that a callback sees the current value.
Once you think in terms of “which render created this function?”, the rules below stop looking arbitrary. The dependency array tells React which render’s values an effect needs. Functional updaters let a callback get the latest state without capturing it. Refs are the explicit escape hatch for a mutable value that every render shares.
useEffect — The Full Picture
useEffect(() => {
// Side effect runs AFTER render
const handler = () => console.log('scrolled')
window.addEventListener('scroll', handler)
// Cleanup runs BEFORE next effect and on unmount
return () => {
window.removeEventListener('scroll', handler)
}
}, []) // [] = run once on mount / cleanup on unmount
Dependency array rules
// ❌ Missing dependency — stale closure bug
useEffect(() => {
fetchUser(userId) // if userId changes, effect doesn't re-run
}, [])
// ✅ Correct
useEffect(() => {
fetchUser(userId)
}, [userId])
// ❌ Object in dependencies — new reference every render = infinite loop
useEffect(() => {
fetchData(options)
}, [options]) // { page: 1 } !== { page: 1 }
// ✅ Primitive values in dependencies
useEffect(() => {
fetchData({ page, limit })
}, [page, limit])
React compares dependencies with Object.is, which checks identity for objects and arrays. { page: 1 } created in two renders is two different objects, so the effect runs on every render. If that effect then sets state, the state change causes another render, which creates another new object, and the loop never ends. The react-hooks/exhaustive-deps lint rule catches missing dependencies, but it cannot tell you that a dependency is unstable. Look out for any dependency that is an object, array, or function defined in the component body.
The fix is usually not to add memoization, but to move things. If an object is only used inside the effect, create it inside the effect. If it does not depend on props or state, move it outside the component. Depend on primitive values (options.page) rather than the whole object. useMemo is the last option, not the first.
Common cleanup patterns
// Fetch with abort controller (prevents state update on unmounted component)
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err)
})
return () => controller.abort()
}, [id])
// WebSocket
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/ws')
ws.onmessage = (e) => setMessages(m => [...m, JSON.parse(e.data)])
return () => ws.close()
}, [])
// Interval
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000)
return () => clearInterval(id)
}, [])
The abort controller does more than silence a warning. Its main job is to prevent a race condition. If id changes from 1 to 2 while the request for user 1 is still in flight, and that request resolves after the request for user 2, then without cleanup the UI shows user 1’s data on user 2’s page. Slow networks make the order of responses unpredictable, so this is a real bug, not a theoretical one. Aborting the old request in the cleanup guarantees that only the latest one can set state. If a library does not accept an AbortSignal, use a local let ignore = false flag that the cleanup sets to true, and check it before calling setState.
In development, React 18+ Strict Mode deliberately mounts every component, unmounts it, and mounts it again. So these effects run, clean up, and run again. This regularly confuses people: “my WebSocket connects twice” or “my API is called twice in development”. It is intentional. Strict Mode is checking that your cleanup really undoes the effect. If the double run causes a visible bug, such as two open sockets, duplicate subscriptions, or an analytics event sent twice, the same bug will happen in production whenever the component remounts. The right fix is to make the cleanup complete, not to disable Strict Mode.
This is one of the most common hook issues I see after an upgrade to React 18: an effect that “worked” for years starts to misbehave in development only. Nearly every time, the cause is an effect whose cleanup was missing or incomplete. The component simply never remounted before, so nobody noticed.
Stale Closures — The #1 Hook Bug
// ❌ Stale closure: count is always 0 inside the callback
function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
console.log(count) // always 0!
setCount(count + 1) // always sets to 1
}, 1000)
return () => clearInterval(id)
}, []) // count not in deps — stale
return <div>{count}</div>
}
// ✅ Fix 1: Functional updater (doesn't need current value in closure)
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1) // always correct
}, 1000)
return () => clearInterval(id)
}, [])
// ✅ Fix 2: Add to dependencies
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(id)
}, [count]) // re-creates interval when count changes
// ✅ Fix 3: useRef for values you don't want to re-trigger effects
function useLatest<T>(value: T) {
const ref = useRef(value)
useEffect(() => { ref.current = value })
return ref
}
function Timer({ onTick }: { onTick: () => void }) {
const onTickRef = useLatest(onTick)
useEffect(() => {
const id = setInterval(() => onTickRef.current(), 1000)
return () => clearInterval(id)
}, []) // no dependency on onTick, but always calls latest version
}
Each fix has a cost. The functional updater is the best option when the only reason for the dependency is to compute the next state. It does not help when you need the current value for something else, such as logging it or sending it to a server. Adding the dependency is always correct, but here it tears down and recreates the interval every second, which resets the timer phase. For a clock that is harmless, but for a polling interval it means requests do not happen at a steady rate.
The ref pattern (useLatest) removes the dependency entirely, and it is what the useEffectEvent hook (stable since React 19.2) formalizes. It has one subtle edge: the ref is updated in an effect, so for a very short moment after a render, code that reads the ref can still see the previous value. For timers and event listeners this never matters. Just do not use this pattern for values read during rendering itself.
useMemo — When It Helps
// ❌ Unnecessary — computation is cheap
const doubled = useMemo(() => count * 2, [count])
// Just do: const doubled = count * 2
// ✅ Use for expensive computations
const filteredUsers = useMemo(() =>
users.filter(u => u.name.toLowerCase().includes(search.toLowerCase())),
[users, search]
)
// ✅ Stable object reference for useEffect dependency
const queryOptions = useMemo(
() => ({ page, limit, filters }),
[page, limit, filters]
)
useEffect(() => {
fetchData(queryOptions)
}, [queryOptions]) // won't loop — same reference when values unchanged
// ✅ Expensive transformation (1000+ items)
const sortedAndGrouped = useMemo(() => {
return groupBy(
sortBy(products, 'price'),
'category'
)
}, [products])
“Expensive” is harder to judge than it sounds, so measure instead of guessing. Wrap the computation in console.time and render with realistic data, or use the React DevTools Profiler. As a rough guide, a computation that takes under a millisecond is not worth memoizing, because useMemo itself costs a dependency comparison and keeps the old value in memory. Filtering a few hundred items is usually well under a millisecond. Sorting and grouping thousands of items on every keystroke often is not.
The queryOptions example has a hidden dependency problem: filters must itself be stable. If the parent passes filters={{ status: 'active' }} inline, it is a new object on every render, the useMemo recomputes each time, and the effect loops just as before. Memoization only works if every dependency in the chain is stable, which is why this approach becomes fragile in larger component trees.
useCallback — Preventing Child Re-renders
// ❌ Passes new function reference every render — child always re-renders
function Parent() {
const [count, setCount] = useState(0)
const handleClick = () => console.log('clicked') // new function each render
return <MemoizedChild onClick={handleClick} />
}
// ✅ Stable reference
function Parent() {
const [count, setCount] = useState(0)
const handleClick = useCallback(() => {
console.log('clicked')
}, []) // stable — same function reference
return <MemoizedChild onClick={handleClick} />
}
const MemoizedChild = memo(({ onClick }) => {
console.log('child rendered')
return <button onClick={onClick}>Click</button>
})
// ✅ useCallback with dependencies
const handleSearch = useCallback((query: string) => {
setPage(1)
setSearch(query)
}, []) // setPage and setSearch are stable (from useState)
// ✅ useCallback for event handlers passed to lists
const handleDelete = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id))
}, [])
useCallback by itself never prevents a re-render. It only keeps the function reference stable. That stability is useful in two cases: when the function is passed to a component wrapped in memo (which skips rendering when all props are shallow-equal), or when the function is a dependency of an effect or of another hook. If the child is not memoized, the child re-renders anyway whenever the parent does, and the useCallback only adds overhead. A lot of production code wraps every handler in useCallback “for performance” with no memo child anywhere, which gives more code and more dependency arrays to get wrong, without any benefit.
One more thing: memo compares all props. If MemoizedChild also receives style={{ color: 'red' }} or items={data.filter(...)}, those are new every render, and the stable callback makes no difference. If you use React Compiler (1.0 was released in late 2025), it inserts this memoization automatically during the build, and most hand-written useMemo/useCallback becomes unnecessary. For more on when manual memoization pays off, see useMemo and useCallback Optimization.
useReducer — Complex State Logic
type State = {
status: 'idle' | 'loading' | 'success' | 'error'
data: User[] | null
error: string | null
}
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User[] }
| { type: 'FETCH_ERROR'; payload: string }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading', data: null, error: null }
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload, error: null }
case 'FETCH_ERROR':
return { status: 'error', data: null, error: action.payload }
}
}
function UserList() {
const [state, dispatch] = useReducer(reducer, {
status: 'idle', data: null, error: null
})
useEffect(() => {
dispatch({ type: 'FETCH_START' })
fetchUsers()
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }))
}, [])
if (state.status === 'loading') return <Spinner />
if (state.status === 'error') return <ErrorMessage message={state.error!} />
return <ul>{state.data?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
The advantage of the reducer here is that impossible states cannot be represented. With three separate useState calls, it is easy to end up with loading: false, error: 'timeout', data: [...] after a retry, because one of the setters was forgotten. Each reducer case returns a complete, consistent state, and TypeScript checks that every action is handled. The reducer is also a pure function, so you can unit test the state transitions without rendering anything.
Custom Hooks
Extract reusable stateful logic into custom hooks:
// useFetch — data fetching with loading/error state
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const controller = new AbortController()
setLoading(true)
fetch(url, { signal: controller.signal })
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
.then(data => { setData(data); setError(null) })
.catch(err => { if (err.name !== 'AbortError') setError(err) })
.finally(() => { if (!controller.signal.aborted) setLoading(false) })
return () => controller.abort()
}, [url])
return { data, loading, error }
}
// useLocalStorage — sync state with localStorage
function useLocalStorage<T>(key: string, defaultValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : defaultValue
} catch { return defaultValue }
})
const setStoredValue = useCallback((newValue: T | ((prev: T) => T)) => {
setValue(prev => {
const resolved = typeof newValue === 'function'
? (newValue as (prev: T) => T)(prev)
: newValue
localStorage.setItem(key, JSON.stringify(resolved))
return resolved
})
}, [key])
return [value, setStoredValue] as const
}
// useDebounce — debounce a rapidly-changing value
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
// Usage
function SearchInput() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 300)
const { data, loading } = useFetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`)
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
{loading && <Spinner />}
{data?.map(item => <div key={item.id}>{item.name}</div>)}
</>
)
}
Note the finally in useFetch. The obvious version, .finally(() => setLoading(false)), has a race. When url changes, the cleanup aborts the old request and the new effect sets loading to true. But the aborted request’s promise still settles a moment later, and its finally sets loading back to false while the new request is still in flight. The spinner disappears too early, and the old data stays on screen with no indication that it is stale. Checking controller.signal.aborted keeps an old request from touching state after it was cancelled. The same rule applies to every async callback in an effect: after cleanup, it must not set state.
useLocalStorage writes to storage inside the setValue updater. Updaters are supposed to be pure. In Strict Mode, React calls them twice in development to expose side effects. Writing the same value twice is harmless here, but moving the write into a useEffect on [key, value] is cleaner. Also note that localStorage does not exist during server-side rendering, and that this hook does not sync between tabs (listen to the storage event for that).
These hand-written hooks are good for learning, but for data fetching in a real application, a library like TanStack Query or SWR handles caching, deduplication, retries, and these race conditions for you. I would still write useDebounce and usePrevious by hand. I would not write my own fetching cache again.
useRef — Beyond DOM Access
// Store previous value
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined)
useEffect(() => { ref.current = value })
return ref.current
}
// Interval that can be updated without re-subscribing
function useInterval(callback: () => void, delay: number) {
const callbackRef = useRef(callback)
useEffect(() => { callbackRef.current = callback })
useEffect(() => {
const id = setInterval(() => callbackRef.current(), delay)
return () => clearInterval(id)
}, [delay])
}
// Track mounted state (prevent setState on unmounted)
function useIsMounted() {
const mounted = useRef(false)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
return mounted
}
Refs are for values that must survive across renders without causing a render when they change: timer IDs, the latest callback, previous values, DOM nodes. Reading or writing ref.current during render (outside effects and handlers) makes the output depend on something React does not track. Avoid it, except for lazy initialization.
useIsMounted is included because it appears in many codebases, but it is now considered an anti-pattern. React 18 removed the “can’t perform a React state update on an unmounted component” warning that it was designed to silence, because the warning was mostly false alarms. An isMounted check hides the symptom without stopping the underlying work (the request still runs, the subscription still exists). Cancelling the work in the effect cleanup, as with the abort controller above, fixes the actual problem. useRef<T>() with no argument is also a type error with React 19’s types, which is why usePrevious passes undefined explicitly.
React 19: use() Hook
// React 19: read promises directly in components
import { use, Suspense } from 'react'
// Create promise outside component (stable reference)
const userPromise = fetchUser('123')
function UserProfile() {
const user = use(userPromise) // suspends until resolved
return <h1>{user.name}</h1>
}
// Wrap in Suspense
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
The comment “create promise outside component” is the important part. use() suspends until the promise resolves, and when the component renders again, it needs to receive the same promise object. If you write use(fetchUser(id)) inside the component, each render creates a new promise, which suspends again, which renders again, in an endless loop. In practice, promises passed to use() come from a cache, a framework loader, or a Server Component that passes them down as props. Unlike other hooks, use() may be called inside conditions and loops.
Hooks Decision Guide
| Need | Hook |
|---|---|
| Simple state | useState |
| Complex state with actions | useReducer |
| Side effects / subscriptions | useEffect |
| Expensive computation | useMemo |
| Stable callback reference | useCallback |
| DOM access / mutable value | useRef |
| Shared logic across components | Custom hook |
| Reading context | useContext |
| Server-side data (React 19) | use() |
The mental model behind most hook bugs
The biggest source of React hook bugs is treating hooks like lifecycle methods from class components. An effect is not “run this on mount”; it is “keep this external thing synchronized with these values”. Read that way, the rules stop being arbitrary: effects should be safe to run more than once, cleanup should undo exactly what the effect set up, and the dependency array should list every value the effect reads, because leaving one out is how a stale closure gets in.
Related Articles
- React from Usage to Internals: Fiber Reconciliation, Diffing, Hooks and Concurrent Rendering
- React useMemo and useCallback: When to Use Them
- What TypeScript 5 Changed