A stale closure is a bug only when a callback's contract requires a newer render value. Use a functional updater for previous-state changes, complete dependencies for re-synchronization, and a ref or Effect Event for a justified latest read in long-lived logic. Preserve an intentional action-time snapshot. If the closure captured the right query but results arrive out of order, diagnose an async race instead.
Frontend interview practice question
React Stale Closure Case Files: Diagnose Six Pull Requests
React stale closures: direct answer
Interview focus
This React interview question tests whether you can explain React Stale Closures: 6 PRs, Which Fix Is Right, connect it to production trade-offs, and handle common follow-up questions.
- React Stale Closures: 6 PRs, Which Fix Is Right explanation without falling back to memorized definitions
- Hooks and Closure reasoning, edge cases, and production failure modes
- How you would answer the most likely React interview follow-up
Use this React interview question to rehearse a quick answer, common mistake, follow-up, and production pitfall.
Callback contract
Before changing a dependency array, decide which value contract the callback needs. That decision separates stale closures from intentional snapshots and ordering races.
- Previous stateDerive the next state from React's committed value.
- Re-synchronizationRestart an external synchronization when its inputs change.
- Latest readRead fresh committed data without expanding an Effect lifecycle.
- Invocation snapshotPass or preserve the value that initiated delayed work.
- Completion orderingDecide which asynchronous result still owns the commit.
React stale closure case files
Predict each failure, choose its contract, then reveal the minimal patch and proof. Every case is deterministic and fully readable without running React code.
Case fileInterval counter: update from previous stateWhy does the counter stop at 1 even though the interval keeps firing?
Trace before you patch
Observed: The interval callback was created during the first render, so every tick computes 0 + 1.
Render 1 creates a callback whose count value is 0.
The first tick requests count = 1 and React commits the update.
Later ticks still request count = 1 because the closed-over value did not change.
Predict the observable result
After three interval ticks, what should the broken UI display?
Re-read the three-step trace and try again.
Choose the value-ownership contract
Which state contract matches this callback?
This contract changes the intended ownership. Try again.
VerdictReveal review prescription
Use the functional state updater
This is a stale closure. The callback needs the previous committed value, not the render snapshot that installed the interval.
Common misdiagnosisAdding count to the dependencies fixes freshness but resets interval cadence; use the updater contract instead.
Keep the interval lifecycle stable and move the changing value into React's updater contract.
Minimal code changeuseEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1_000);
return () => clearInterval(id);
}, []);useEffect(() => {
const id = setInterval(() => {
setCount(current => current + 1);
}, 1_000);
return () => clearInterval(id);
}, []);expect(screen.getByText('Count: 3')).toBeInTheDocument();Case fileChat connection: read the latest theme without reconnectingHow can the connection depend on roomId while its notification sees the latest theme?
Trace before you patch
Observed: Adding theme to the Effect dependencies fixes the notification color but reconnects the chat whenever appearance changes.
roomId owns the connection lifecycle and must trigger a resubscription.
theme is only read when the connected event fires; it should not own that lifecycle.
The callback therefore needs a latest non-reactive read, not a broader dependency list.
Predict the observable result
What happens when theme changes if it is added to the connection Effect dependencies?
Re-read the three-step trace and try again.
Choose the value-ownership contract
Which ownership contract fits the notification callback?
This contract changes the intended ownership. Try again.
VerdictReveal review prescription
Use an Effect Event when React 19.2+ is available
The shown PR is over-synchronized, not currently stale: adding theme keeps the notification fresh but incorrectly makes appearance own the connection lifetime. The underlying requirement is a latest non-reactive read.
Common misdiagnosisRemoving theme alone stops reconnects but reintroduces a stale notification callback.
Keep roomId as the connection dependency. Let the event callback read the latest theme without causing a reconnect.
Minimal code changeuseEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
showNotification('Connected', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);const onConnected = useEffectEvent(() => {
showNotification('Connected', theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]);useEffectEvent is available in React 19.2+. It reads the latest committed values for Effect-owned event work; do not pass it to components or hooks, hide genuine dependencies with it, or depend on a stable identity. On React 18, mirror theme into a ref from an Effect and read that ref from the connection callback.
const latestTheme = useRef(theme);
useEffect(() => {
latestTheme.current = theme;
}, [theme]);
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
showNotification('Connected', latestTheme.current);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);expect(connect).toHaveBeenCalledTimes(1);
expect(showNotification).toHaveBeenLastCalledWith('Connected', 'dark');Case fileEscape listener: re-synchronize with isDirtyWhy does Escape close the editor without warning after the form becomes dirty?
Trace before you patch
Observed: The window listener was installed once and still reads the initial false value for isDirty.
The first render installs the Escape handler while isDirty is false.
Editing commits isDirty = true, but the external listener is not replaced.
Escape follows the old branch and closes without asking for confirmation.
Predict the observable result
What does the empty-dependency listener do after the form becomes dirty?
Re-read the three-step trace and try again.
Choose the value-ownership contract
Which contract should own this listener?
This contract changes the intended ownership. Try again.
VerdictReveal review prescription
Declare isDirty as an Effect dependency
This is a stale closure. isDirty changes the listener's behavior, so it belongs to the synchronization contract.
Common misdiagnosisA ref is not the default fix; it can hide behavior that should re-synchronize.
Remove and re-register the external listener when the behavior it owns changes. Cleanup prevents duplicate listeners.
Minimal code changeuseEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeEditor(isDirty);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeEditor(isDirty);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isDirty]);rerender(<Editor isDirty />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(confirmClose).toHaveBeenCalledTimes(1);Case fileDebounced autosave: pass the invocation snapshotWhy does the save request keep sending the first draft?
Trace before you patch
Observed: The memoized debounced function closes over the first render's draft, even when later clicks schedule new work.
The memo factory runs once and captures draft A.
A later render shows draft B, but it calls the same debounced function.
When the queued callback runs, its closure still sends draft A.
Predict the observable result
Which draft reaches onSave after the user schedules autosave from draft B?
Re-read the three-step trace and try again.
Choose the value-ownership contract
Where should this value cross the debounce boundary?
This contract changes the intended ownership. Try again.
VerdictReveal review prescription
Make the queued value an argument
This is a stale closure. The debounced worker is stable, but its payload should come from each invocation.
Common misdiagnosisAdding draft to the memo dependencies recreates the debouncer and can strand pending work.
Keep one debounced worker, pass the draft at invocation time, and cancel pending work on unmount.
Minimal code changeconst saveLater = useMemo(
() => debounce(() => onSave(draft), 300),
[onSave],
);
const scheduleSave = () => saveLater();const saveLater = useMemo(
() => debounce((nextDraft: Draft) => onSave(nextDraft), 300),
[onSave],
);
useEffect(() => () => saveLater.cancel(), [saveLater]);
const scheduleSave = () => saveLater(draft);scheduleSaveWith('draft A');
scheduleSaveWith('draft B');
advanceTimersByTime(300);
expect(onSave).toHaveBeenLastCalledWith('draft B');
scheduleSaveWith('draft C');
unmount();
advanceTimersByTime(300);
expect(onSave).not.toHaveBeenCalledWith('draft C');Case fileExport audit: preserve the initiating snapshotShould an audit record the filters at click time or whatever filters exist after export finishes?
Trace before you patch
Observed: A reviewer calls the captured filters stale, but the product contract requires the initiating snapshot.
The user starts an export while filters equal “paid”.
The user changes the live view to “trial” while the export is pending.
The audit must describe the exported “paid” dataset, not the later screen state.
Predict the observable result
Which filters should the completed export audit record?
Re-read the three-step trace and try again.
Choose the value-ownership contract
Which contract makes that capture correct?
This contract changes the intended ownership. Try again.
VerdictReveal review prescription
Name the snapshot to make intent reviewable
This is not a stale-closure bug. The closure preserves the exact state that initiated a side effect whose result must stay attributable.
Common misdiagnosisReplacing the snapshot with a latest-value ref corrupts audit attribution.
Capture and name filtersAtStart before the asynchronous boundary. The explicit name protects a correct snapshot from an incorrect “always latest” refactor.
Minimal code changeasync function exportRows() {
const file = await createExport(filters);
audit({ fileId: file.id, filters });
}async function exportRows() {
const filtersAtStart = filters;
const file = await createExport(filtersAtStart);
audit({ fileId: file.id, filters: filtersAtStart });
}expect(audit).toHaveBeenCalledWith({
fileId: 'export-1',
filters: filtersAtClick,
});Case fileAsync search: diagnose a race, not a closureWhy can an older result overwrite the latest query even when each request captured the right query?
Trace before you patch
Observed: Request A starts first, request B finishes first, and A later commits over B.
The closure for request A correctly captures query A.
The closure for request B correctly captures query B.
Completion order differs from intent order, so the stale commit is an ownership race.
Predict the observable result
If B resolves before A and both callbacks commit, which result remains visible?
Re-read the three-step trace and try again.
Choose the value-ownership contract
Which contract prevents the obsolete commit?
This contract changes the intended ownership. Try again.
VerdictReveal review prescription
Guard the commit with a request generation
This is a race condition, not a stale closure. Both callbacks captured the intended query; the missing rule is which request may commit.
Common misdiagnosisAdding query dependencies cannot stop an older completion from committing last.
Increment an owner token for each search and ignore completions that no longer own the latest generation.
Minimal code changeasync function search(query: string) {
const result = await fetchResults(query);
setResults(result);
}const latestRequest = useRef(0);
async function search(query: string) {
const requestId = ++latestRequest.current;
const result = await fetchResults(query);
if (requestId !== latestRequest.current) return;
setResults(result);
}resolveSearch('B');
resolveSearch('A');
expect(screen.getByText('Result B')).toBeInTheDocument();Diagnosis table and production review
Diagnosis table
Use this table after making all six predictions, not as an answer key before the clinic. Each row compresses a review into four claims: the case-file boundary, the behavior the callback must preserve, the actual failure category, and the smallest safe repair direction. Follow the fragment link back to the representative diff, compare your prediction with its revealed verdict, then write the review comment in observable terms: what the user sees, which retained callback or completion owns that result, why the proposed change is unsafe, and which assertion would fail before the patch. “Minimal” does not mean shortest syntax; it means the least change that fixes the violated contract without changing timer cadence, reconnecting an external resource, losing cleanup, rewriting an intentional audit snapshot, or masking request ordering. If more than one patch is technically valid, prefer the one whose lifecycle and test are easiest for the next reviewer to verify.
Case file | Required callback contract | Diagnosis | Minimal safe direction |
|---|---|---|---|
Calculate from queued previous state. | A retained interval reads an old count. | Use a functional updater; add count only if timer reset is intentional. | |
Keep room setup reactive while reading the latest theme. | Removing theme from dependencies makes notification logic stale. | Use an Effect Event, or an explicit React 18 latest-read design. | |
Re-synchronize when isDirty or the handler changes. | An empty-dependency Effect retains the first guard state. | Use complete dependencies and symmetric cleanup; justify a latest-value ref. | |
Save the latest invocation snapshot. | Memoization retains the first draft or save function. | Pass draft as an argument, declare owner dependencies, and cancel pending work. | |
Preserve the initiating filters. | Intentional snapshot, not a stale-closure bug. | Keep and name the capture; a latest-value ref would change audit meaning. | |
Only the latest request may commit. | Async race, not a stale closure, when each request captured its query. | Abort superseded work or enforce request-generation ownership. |
Production code-review checklist
- Name the boundary: timer, Effect, listener, retained utility, promise, or request.
- Classify the value: previous state, reactive input, latest read, or intentional snapshot.
- Inspect ownership: identify who retains and replaces the callback.
- Respect complete dependencies: remove one only after making it non-reactive.
- Require symmetric cleanup: remove, cancel, or disconnect what setup created.
- Separate freshness from ordering: current state does not prevent an older request winning.
- Check the runtime: React 19.2 APIs need a React 18.3.1 fallback here.
- Drive the delay: rerender, fire, resolve out of order, or unmount in the test.
Proof beats a plausible hook name
Reproduce the delayed behavior and reject the tempting wrong fix. Advance several interval ticks without recreating setup. Change a chat theme without reconnecting and assert the next notification uses it. Rerender before dispatching a retained listener, then unmount and prove the listener no longer runs. Invoke debounce rapidly and prove only the final argument saves; unmount must cancel it. Preserve initiating filters while an export waits. Resolve search B before A and prove A cannot overwrite B—the async race-condition walkthrough develops that ownership test separately.
Why the tempting fixes fail
Dependencies can correctly rebuild an external resource, but may change its cadence. Omitting them can retain obsolete logic. A functional updater solves previous-state calculation, not latest props. A ref supports a justified latest read but must not hide reactive synchronization. useCallback controls identity; missing dependencies still capture old values, and a consumer may retain an older function. Choose from the contract, not a favorite hook.
React version boundary: useEffectEvent
In React 19.2+, useEffectEvent can read latest committed values in non-reactive Effect-only logic without reconnecting an unrelated resource. It must not be passed to other components or hooks, used as a normal event handler, or used to hide genuine dependencies. FrontendAtlas runs React 18.3.1 sandboxes, so every recommendation needs a compatible ref or restructuring alternative.
Practice the underlying decisions
Use JavaScript closures for lexical capture and the useEffect guide for synchronization. Compare useRef versus useState and useMemo versus useCallback, then apply them in debounced React search. Diagnose StrictMode double setup separately from stale reads.
30-second interview answer
Each React render creates new bindings, and a callback retains the render that created it. It is stale only when its contract requires newer data. I use a functional updater for previous-state changes, complete dependencies for re-synchronization, and a ref or Effect Event for a justified latest read. I preserve intentional snapshots and diagnose request ordering separately because an async race can look stale without being a closure bug.
Interview follow-ups worth practicing
Explain why a dependency can reset timer cadence, why functional updates cannot provide latest props, why refs do not render, and why useCallback can still be stale. Also distinguish an export snapshot from current permission and propose a rerender-plus-delayed-callback test. Continue with React interview questions or the React preparation path.
Use this as one explanation rep, then continue with the React interview questions cluster or a guided prep path.