TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Fix Stale State in React Event Handlers

Practical insights, clear explanations, and useful ideas for understanding the web.

October 26, 20267 min read6 topics
How to Fix Stale State in React Event Handlers
Explore the details
#React#React state#JavaScript closures#useState#useEffect#useRef
October 26, 20267 min read

A timer or callback can keep the state value from the render in which it was created. When it runs later, it may overwrite newer state or act as if nothing changed. This guide shows how to reproduce that stale closure, and how to choose between a functional state update, effect dependencies, and a ref.

Key Takeaways

  • React state is a snapshot for each render. A callback created during that render closes over that snapshot.
  • Use a functional state update when the next value depends on the previous value: setCount(current => current + 1).
  • Include reactive values in an effect's dependencies when the effect's callback needs to track those values. This may recreate a timer or subscription.
  • Use a ref when a long-lived callback needs to read the latest value without resubscribing. Updating a ref does not trigger a render.

Why a callback sees stale state

On each render, React gives your component a particular state snapshot and creates its event-handler functions. Those functions keep the values from that render, even if they run later. Calling a state setter schedules another render; it does not change the state variable inside a callback that is already running.

This does not mean every event handler is stale. A normal button handler is recreated on render, and React uses the handler from the current render. Stale reads tend to appear when a callback outlives the render that created it—for example, a timer, a promise continuation, or an event listener registered only once.

Here is a small example. Click Start delayed increment, then click Increment now before the timer fires. The timer captured count when Start was clicked. If that value was 0, it later sets the count to 1, even though the immediate click already changed it to 1.

import { useState } from 'react';

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

  function startDelayedIncrement() {
    setTimeout(() => {
      setCount(count + 1);
    }, 2000);
  }

  return (
    <section>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment now</button>
      <button onClick={startDelayedIncrement}>Start delayed increment</button>
    </section>
  );
}

The issue is not that setTimeout behaves differently in React. The callback simply uses the count value captured by startDelayedIncrement from its render.

Fix 1: Use a functional state update

When the intended change depends on the latest previous state, pass a function to the setter:

function startDelayedIncrement() {
  setTimeout(() => {
    setCount(current => current + 1);
  }, 2000);
}

React supplies the pending state value to the updater, so the delayed increment applies to the latest count rather than the value captured when the timer started. This is also important when queuing multiple updates based on the same state:

setCount(current => current + 1);
setCount(current => current + 1);

These two updater functions build on each other's results. By contrast, two calls such as setCount(count + 1) use the same captured count value.

Choose this fix when you are calculating new state from previous state—such as incrementing, toggling, or adding an item to a list. It does not refresh other captured values. If the update also depends on a changing step, for example, decide whether to capture the step from when the action began or make the callback track the current step.

Fix 2: Declare effect dependencies

An effect that registers a long-lived callback can capture an old value too. This interval only ever sets the count to the initial count plus one:

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);

  return () => clearInterval(id);
}, []);

If the interval's job is simply to increment, use a functional update. The callback no longer needs to read count, so the effect can keep the same interval without depending on it:

useEffect(() => {
  const id = setInterval(() => {
    setCount(current => current + 1);
  }, 1000);

  return () => clearInterval(id);
}, []);

If the effect genuinely needs the current value—for example, to synchronize an external subscription—include that reactive value in the dependency list. React will clean up and run the effect again when it changes:

useEffect(() => {
  function handleChange() {
    console.log('Current count:', count);
  }

  window.addEventListener('some-event', handleChange);
  return () => window.removeEventListener('some-event', handleChange);
}, [count]);

This keeps the listener's closure current, but it also replaces the listener whenever count changes. That trade-off may be fine for an inexpensive subscription; for a subscription that should remain stable, consider whether a functional update or a ref is a better fit. Do not omit dependencies just to prevent an effect from running again: the callback can then keep outdated values.

Fix 3: Keep a latest value in a ref

A ref can hold a value that a stable callback reads without needing to recreate that callback on every render. This is useful for an external listener or timer that must stay registered while reading the latest state.

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

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

  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const id = setInterval(() => {
      console.log('Latest count:', countRef.current);
    }, 1000);

    return () => clearInterval(id);
  }, []);

  return (
    <button onClick={() => setCount(current => current + 1)}>
      Count: {count}
    </button>
  );
}

The first effect copies each committed count into the ref. The interval reads countRef.current, not the state snapshot captured when the interval was created. A ref update does not render the component, so use state—not a ref—for values that need to appear in the UI.

A ref is not a general way to silence dependency warnings. Use dependencies when an effect must synchronize with a changing value. Use a ref when a stable callback needs to read the latest value and keeping its subscription stable is intentional. Consider timing: this example updates the ref in an effect after a render commits; code that needs a value synchronously at a particular event boundary may need a different design.

Which fix fits?

SituationUsually the right fitReason
A delayed increment or toggle depends on previous stateFunctional updateThe update receives the latest pending state.
An effect's subscription or resource must be recreated when a value changesEffect dependencyCleanup and setup stay synchronized with that value.
A stable external callback must read the latest state without being recreatedRefThe callback reads mutable current data without causing a render.
A value should update the rendered interfaceStateRef changes do not trigger rendering.

Common Mistakes

  • Assuming a setter changes the current variable. A setter schedules an update; the variable in the current render remains that render's snapshot.
  • Using a functional update for every stale value. It solves calculations based on previous state, but it does not refresh other values captured by the callback.
  • Adding an empty dependency array to avoid reruns. If the effect reads changing values, an empty array can leave its callback with the initial snapshot.
  • Adding a dependency without considering cleanup. Re-running an effect may recreate a timer, reconnect a socket, or replace a listener. Check whether that lifecycle is intended.
  • Putting display state in a ref. Mutating a ref alone does not update the UI.

Best Practices

  1. Identify which render created the callback and which values it captured.
  2. Ask whether the callback needs the latest state or specifically the state from when the action began. Those are different behaviors.
  3. Prefer a functional updater when the change depends only on the previous state.
  4. Treat an effect's dependency list as part of its synchronization logic, and clean up timers and listeners it creates.
  5. Use refs for intentionally stable callbacks that need a latest-value read, not as a substitute for state or effect dependencies.

Conclusion

A stale state closure is a callback reading the snapshot from the render that created it. Use a functional update for state changes based on the previous value, effect dependencies when synchronization should follow changing values, and a ref when a stable callback needs to read the latest value. Choosing based on the callback's actual job avoids both stale reads and unnecessary effect churn.

References