When not to use an effect, and stale closures
Most effects that set state from props or state are an extra render waiting to happen. What to do instead, and how an effect ends up reading old values.
After this lesson you can
- Replace an effect that derives state with a calculation during render
- Put logic caused by a user action in the event handler, not an effect
- Explain a stale closure in an effect or timer and fix it two ways
Effects exist to synchronise with systems outside React. A large share of the effects in real code do something else — they copy one piece of React state into another — and each one costs an extra render pass and a commit in which the DOM holds an out-of-date value.
Derived values are computed, not stored
// Avoid: state that mirrors other state
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(first + " " + last);
}, [first, last]);
// Do: compute during render
const fullName = first + " " + last;
The effect version renders with the stale fullName, commits that to the
DOM, runs the effect, sets state, and renders again. (Whether the browser
actually paints the stale frame depends on timing, but the wasted render
and commit happen either way.) The model below simply writes those two
sequences out side by side; it illustrates the order of steps, it does
not measure React:
// Simulates what reaches the screen after `first` changes from "Ada" to "Grace".function withEffect() { const commits = []; let fullName = "Ada Lovelace"; // state copied by the effect last time const first = "Grace", last = "Lovelace"; commits.push(fullName); // render 1 uses the old state fullName = first + " " + last; // effect runs after commit, sets state commits.push(fullName); // render 2 return commits;} function derived() { const first = "Grace", last = "Lovelace"; return [first + " " + last]; // one render, already correct} export function run() { return { withEffect: withEffect(), derived: derived() };}If the calculation is expensive, wrap it in useMemo rather than moving
it into an effect.
Other effects you do not need
- Responding to a user action. "When the user clicks Buy, send the
order and show a toast" belongs in the click handler. An effect that
watches
purchasedwould also fire whenever the component mounts withpurchasedalready true, for example after navigating back. - Resetting all state when a prop changes. Give the component a key
(
<Profile key={userId} />) instead of an effect that clears each field. - Telling a parent about a change. Call the parent's callback in the same handler that changed the state, so both update in one render.
- Chains of effects where one sets state that triggers the next. Compute the whole next state in the handler.
Effects remain right for subscriptions, timers, network synchronisation, manual DOM integrations and analytics on display — things that happen because the component is on screen, not because of a particular click.
Stale closures
Every function created during a render captures that render's values.
An effect with [] dependencies is set up once, so the callback it
creates sees the first render's values forever:
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // count is always the value from the first render
// `state` is React's stored value; each render gets a snapshot of it.function simulate(useUpdater) { let state = 0; const snapshotAtMount = state; // the render the effect was set up in const setCount = (next) => { state = typeof next === "function" ? next(state) : next; }; const tick = useUpdater ? () => setCount((c) => c + 1) : () => setCount(snapshotAtMount + 1); const seen = []; for (let i = 0; i < 3; i++) { tick(); seen.push(state); } return seen;} export function run() { return { valueFromClosure: simulate(false), updater: simulate(true) };}The counter is stuck at 1: every tick computes 0 + 1. Two fixes:
- An updater function,
setCount(c => c + 1), does not need to readcountat all, so the dependency array can stay empty honestly. - List the dependency,
[count]. The interval is then torn down and recreated with a fresh closure each timecountchanges — correct, if a little wasteful.
For a value an effect must read without re-running (the latest callback
prop, for instance), keep it in a ref (lesson 9). React 19.2 added a
stable useEffectEvent hook for exactly that job: it wraps a function
that always sees the latest props and state, and is meant to be called
only from inside effects.
Common mistakes
- Mirroring props or state into state with an effect. It renders twice and commits a stale value in between, the sequence the first example lays out.
- Event logic in an effect keyed on a flag, which then fires again whenever the component mounts with that flag already set.
- Silencing the exhaustive-deps lint rule to stop an effect re-running. The effect then reads stale values; the second example is the result.
What interviewers ask
- How do you know an effect is unnecessary? — If it only transforms props or state into other state, or reacts to a specific user action, it belongs in render or in the handler; effects are for external systems.
- What is a stale closure in React? — A function created in an earlier render (a timer callback, an effect with missing dependencies) still reading that render's values; fix it with an updater function, a correct dependency list, or a ref.
Exercise · Pro
A stale timer and a click
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade