React / Editorial solution guide
Diagnose stale closures and Effect loops
Trace the captured render, actual dependencies, and update cycle before removing dependencies or adding memoization.
Read-only knowledge base · No public comments
Recognize the symptoms
A timer or subscription repeatedly uses an old value, an Effect reconnects on every render, or the interface reports excessive updates. The same screen can appear correct on its first render and fail after a prop changes or development cleanup runs.
Record the triggering interaction and exact diagnostic. Do not execute an intentionally infinite-loop reproduction against a running application merely to confirm the symptom.
Understand the likely causes
- A handler closes over one render’s values. An Effect with a missing reactive dependency can therefore retain stale inputs. Conversely, an Effect that updates state and thereby changes a dependency can form a cycle.
- New object or function identities can trigger repeated setup. Redundant derived state often creates the unnecessary Effect in the first place; the right fix may be removing synchronization rather than forcing a timing trick.
1. Identify the owner of the work
Ask whether the operation is a pure derivation, a user action, or synchronization with an external system. A local filtered list belongs in render; a deliberate Save request belongs in its event handler; a browser subscription can belong in an Effect. Write down the values each callback reads and which render created it. An await or timeout does not refresh that closure automatically. Keep the smallest synthetic input that exposes the stale value, and inspect the saved source rather than relying only on a repeated console line.
2. Trace reads and update edges
List every reactive read in the Effect setup, then draw the path from setup to state update to changed dependency. React compares dependencies with Object.is, so a fresh object can change even when its fields look equal. Do not delete a dependency merely because adding it exposes the cycle. Move setup-only objects inside the Effect, move genuine constants outside the component, or redesign the source of truth. Keep the Hooks dependency linter enabled. Development setup-cleanup-setup is a lifecycle probe, not proof that production will always run one arbitrary effect exactly once.
3. Repair a prior-state update with cleanup
When a timer only needs the next count, use a pure functional updater instead of reading a captured count. The local example installs one interval and clears that exact handle. It does not suppress the lifecycle probe or use a ref to prevent legitimate reconnection. If the callback also reads a current prop that affects synchronization, that dependency still needs proper treatment; functional updates are not permission to omit unrelated reads. Timers are approximate, so tests should control time or assert an owned eventual condition rather than exact wall-clock milliseconds.
import { useEffect, useState } from "react";
export default function App() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => setCount(c => c + 1), 1000); return () => clearInterval(timer); }, []); return <main><h1>Owned interval</h1><p>{count}</p></main>; }
The count increases while mounted; unmount clears its interval.
The interval updates from queued state and has symmetric cleanup. No service or real clock precision is assumed.
4. Remove synchronization that has no external target
If an Effect merely copies a filtered collection or total into another state value, calculate it directly instead. This removes both a stale intermediate result and an update edge. The example’s query drives a pure local filter, so no Effect is necessary. Memoize only after measuring an expensive pure calculation; do not add memoization to conceal an incorrect dependency graph. Recheck any actual external setup separately, including cleanup and outdated async results, because removing one redundant Effect does not establish the correctness of every remaining subscription.
import { useState } from "react";
const names = ["Alpha", "Beta"];
export default function App() { const [query, setQuery] = useState(""); const matches = names.filter(n => n.toLowerCase().includes(query.toLowerCase())); return <main><h1>Derived results</h1><label>Query<input value={query} onChange={e => setQuery(e.target.value)} /></label><p>{matches.join(", ")}</p></main>; }
Typing bet shows Beta.
The result is computed from the current query without duplicated state or a synchronization cycle.
Verify the fix
Repeat the original interaction, then change dependencies, unmount, and remount under development checks. Confirm current values, exactly owned subscriptions, and no update cycle. For asynchronous work, resolve an obsolete attempt after the current attempt and verify that it cannot publish stale data.
Keep it from recurring
- Document each Effect’s external relationship, keep dependency linting active, and test cleanup. Prefer minimal state and event-owned actions to indirect flag-driven workflows.