DEV Community

Cathy Lai
Cathy Lai

Posted on

Common Stale Closure Bugs in React

Here are the most common “stale closure” bugs in React and how to fix each, with tiny, copy-pasteable examples.


1) setInterval using an old state value

Bug: interval callback captured the value of count from the first render, so it never increments past 1.

import React, { useEffect, useState } from 'react';

export default function BadCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // ⚠️ stale closure: callback sees count=0 forever
    const id = setInterval(() => {
      setCount(count + 1); // always 1
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps -> callback never updated

  return <p>{count}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Fix A (recommended): use functional state update (no deps needed).

import React, { useEffect, useState } from 'react';

export default function GoodCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // ✅ always uses the latest value
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <p>{count}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Fix B: include count in deps and recreate interval when it changes (less efficient).

useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, [count]);
Enter fullscreen mode Exit fullscreen mode

2) Async function reads an old prop/state

Bug: searchTerm changes, but the async call uses the old term captured by the earlier render.

import React, { useEffect, useState } from 'react';

export default function BadSearch({ searchTerm }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    async function run() {
      // ⚠️ if not in deps, this may use an old searchTerm
      const res = await fetch(`/api?q=${encodeURIComponent(searchTerm)}`);
      setResults(await res.json());
    }
    run();
  }, []); // ❌ missing searchTerm

  return <pre>{JSON.stringify(results, null, 2)}</pre>;
}
Enter fullscreen mode Exit fullscreen mode

Fix: put the variable in the dependency array (and handle race conditions with an abort flag if needed).

useEffect(() => {
  let cancelled = false;
  (async () => {
    const res = await fetch(`/api?q=${encodeURIComponent(searchTerm)}`);
    const data = await res.json();
    if (!cancelled) setResults(data);
  })();
  return () => { cancelled = true; };
}, [searchTerm]);
Enter fullscreen mode Exit fullscreen mode

3) Event listeners holding stale state/props

Bug: A window listener added once reads old value.

import React, { useEffect, useState } from 'react';

export default function BadListener() {
  const [value, setValue] = useState(0);

  useEffect(() => {
    function onKey(e) {
      // ⚠️ uses value from the first render only
      if (e.key === 'ArrowUp') setValue(value + 1);
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []); // ❌ missing value
}
Enter fullscreen mode Exit fullscreen mode

Fix A: include value in deps so the listener updates (re-attach on change).

useEffect(() => {
  function onKey(e) {
    if (e.key === 'ArrowUp') setValue(v => v + 1);
  }
  window.addEventListener('keydown', onKey);
  return () => window.removeEventListener('keydown', onKey);
}, [/* none needed if using functional update */]);
Enter fullscreen mode Exit fullscreen mode

(Here we used the functional updater, so we don’t need value in deps; the handler always increments from the latest value.)

Fix B (ref pattern): keep latest value in a ref, use a stable handler.

import React, { useEffect, useRef, useState } from 'react';

export default function GoodListener() {
  const [value, setValue] = useState(0);
  const valueRef = useRef(value);
  valueRef.current = value; // keep ref synced

  useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'ArrowUp') setValue(valueRef.current + 1);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  return <p>{value}</p>;
}
Enter fullscreen mode Exit fullscreen mode

4) Throttled/Debounced handlers with stale closures

Bug: throttled/debounced function created once, but references an old state inside.

import React, { useMemo, useState } from 'react';

function throttle(fn, wait) { /* ... as before ... */ }

export default function BadThrottle() {
  const [y, setY] = useState(0);

  const onScroll = useMemo(
    () => throttle((e) => {
      // ⚠️ if we used y directly here, it might be stale
      setY(e.nativeEvent.contentOffset.y);
    }, 300),
    []
  );

  // This one is okay because we set y from the event directly.
  // But if you *read* y inside the throttled function, it would be stale.
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Fix A: avoid reading state inside throttled/debounced callbacks; derive from event payloads when possible.

Fix B (ref pattern): if you must read state, mirror it into a ref and read from the ref inside the throttled handler.

import React, { useMemo, useRef, useState, useEffect } from 'react';

function throttle(fn, wait) { /* ... */ }

export default function GoodThrottle() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  useEffect(() => { countRef.current = count; }, [count]);

  const onEvent = useMemo(
    () => throttle(() => {
      // ✅ always latest via ref
      console.log('latest count =', countRef.current);
    }, 300),
    []
  );

  return <button onClick={() => setCount(c => c + 1)}>Inc</button>;
}
Enter fullscreen mode Exit fullscreen mode

5) Quick checklist to avoid stale closures

□ Use functional state updates: setState(prev => compute(prev))
□ Put every outside variable you use in a hook into the dependency array
□ Or, store the “latest” value in a ref if you need a stable callback
□ Recreate timers/listeners when their deps change (and clean up!)
□ For throttled/debounced funcs, prefer deriving from event args, or read from a ref

Top comments (0)