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…


This content originally appeared on DEV Community and was authored by Cathy Lai

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>;
}

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>;
}

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]);

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>;
}

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]);

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
}

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 */]);

(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>;
}

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;
}

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>;
}

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


This content originally appeared on DEV Community and was authored by Cathy Lai


Print Share Comment Cite Upload Translate Updates
APA

Cathy Lai | Sciencx (2025-09-19T17:55:39+00:00) Common Stale Closure Bugs in React. Retrieved from https://www.scien.cx/2025/09/19/common-stale-closure-bugs-in-react/

MLA
" » Common Stale Closure Bugs in React." Cathy Lai | Sciencx - Friday September 19, 2025, https://www.scien.cx/2025/09/19/common-stale-closure-bugs-in-react/
HARVARD
Cathy Lai | Sciencx Friday September 19, 2025 » Common Stale Closure Bugs in React., viewed ,<https://www.scien.cx/2025/09/19/common-stale-closure-bugs-in-react/>
VANCOUVER
Cathy Lai | Sciencx - » Common Stale Closure Bugs in React. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/09/19/common-stale-closure-bugs-in-react/
CHICAGO
" » Common Stale Closure Bugs in React." Cathy Lai | Sciencx - Accessed . https://www.scien.cx/2025/09/19/common-stale-closure-bugs-in-react/
IEEE
" » Common Stale Closure Bugs in React." Cathy Lai | Sciencx [Online]. Available: https://www.scien.cx/2025/09/19/common-stale-closure-bugs-in-react/. [Accessed: ]
rf:citation
» Common Stale Closure Bugs in React | Cathy Lai | Sciencx | https://www.scien.cx/2025/09/19/common-stale-closure-bugs-in-react/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.