React120 min total · 17 parts
React Fundamentals: Components, Hooks, and the Virtual DOM
Part 8 of 17 · ~8 min
useEffect In Depth
Our dashboard has been faking its data. Time to fetch it, and that means stepping outside React.
Drop "component lifecycle" as your mental model for useEffect — it will mislead you on every hard case. The model that holds up is: an effect keeps this component synchronized with something living outside React. The document title, a subscription, a timer, a network request, localStorage, a keyboard listener on document. Ask what the work actually is: pure computation of what to display, given the current props and state, stays in the render body — anything that reaches past that boundary belongs in an effect instead.
useEffect(() => {
document.title = `${pendingCount} candidates to review`;
}, [pendingCount]);
The dependency array controls when the effect runs again — never whether it runs at all:
| Dependency array | Behavior |
|---|---|
| Omitted entirely | Runs after every render |
[] | Runs once, after the first render |
[pendingCount] | Runs after the first render, and after any render where pendingCount changed |
"Changed" means Object.is against the previous render's value. Which is why an inline object or array in the deps ([{ id }], [[a, b]]) makes the effect run every single render — a fresh reference each time is never equal to the last.
Effects run after React has committed changes to the DOM and the browser has painted. That is what makes them safe for slow work, and it is also why an effect that measures or repositions something can produce a visible flicker: the user sees the un-positioned frame first. useLayoutEffect has the identical signature but runs synchronously after the DOM is updated and before the browser paints, which removes the flicker at the cost of blocking painting. Measuring a tooltip to decide which side to open on is the canonical use; almost everything else should stay in useEffect.
Cleanup functions
An effect may return a function. React calls it before running the effect again, and once more when the component unmounts.
Our reviewers work through candidates with the keyboard — j and k to move down and up, Escape to close the drawer:
useEffect(() => {
function handleKey(e) {
if (e.key === "j") selectNext();
if (e.key === "k") selectPrevious();
if (e.key === "Escape") closeDrawer();
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey); // cleanup
}, [selectNext, selectPrevious, closeDrawer]);
Delete that return statement and here is what a reviewer experiences. Open a drawer, close it, open another — each mount adds a listener and nothing removes one. After ten candidates, pressing j runs ten handlers, nine of which belong to drawers that no longer exist and are reaching for candidates that are no longer selected. The screen jumps several rows per press, and the ten closures are each holding a dead component's variables alive. It is a memory leak and a behaviour bug from the same missing line.
Every subscription needs an unsubscription. Listener, timer, WebSocket, observer, in-flight request: if the effect started it, the cleanup stops it.
There is a second reason this is non-negotiable now. In development, React's Strict Mode deliberately mounts each component, runs its effects, runs the cleanups, and runs the effects again. That is not a bug and it is not something to work around — it is a test. An effect that is correctly paired with its cleanup is unaffected. An effect that leaks, double-subscribes or double-fires shows its symptoms immediately, in development, instead of in production three months later. If a component only works when Strict Mode is off, the effect is broken and Strict Mode found it for you.
The stale closure bug
This is the one. If you take a single thing from this reference, take this one, because almost everyone meets it eventually and it does not look like a React problem when you do.
Our drawer autosaves a draft of the reviewer's notes every ten seconds, so a closed laptop does not lose twenty minutes of writing:
function ReviewForm({ candidateId }) {
const [notes, setNotes] = useState("");
useEffect(() => {
const id = setInterval(() => {
saveDraft(candidateId, notes);
}, 10000);
return () => clearInterval(id);
}, []); // runs once — that's the whole point, right?
return <textarea value={notes} onChange={(e) => setNotes(e.target.value)} />;
}
The reviewer types four hundred words. Every ten seconds, the autosave writes an empty string over the draft.
Go back to the sentence from chapter 3: within a single render, state is a constant. That effect's callback was created during the first render, when notes was "", and it closed over that specific constant. The empty dependency array means the effect never runs again, so no new callback is ever created, so the interval keeps calling the first render's function with the first render's notes — forever. It is not reading a variable that failed to update. It is reading a different variable from the one the textarea is displaying.
Here is the part worth internalising: every render creates a new set of functions, closed over that render's values. An effect that does not re-run is holding a function from an older render, looking at an older world. "Stale closure" is a precise description, not a metaphor.
There are three honest fixes, and choosing between them is a question about intent.
Fix 1 — put the value in the dependency array. Correct, and always available:
useEffect(() => {
const id = setInterval(() => saveDraft(candidateId, notes), 10000);
return () => clearInterval(id);
}, [candidateId, notes]);
Now every change to notes tears down the interval and starts a new one with a fresh closure. Perfectly correct — and for an autosave, slightly silly, because the ten-second timer restarts on every keystroke and a fast typist may never reach ten seconds of quiet. The fix works; it also changed the behaviour.
Fix 2 — use the functional updater, when the value you need is state you are also setting. No dependency required, because you never read the captured value:
useEffect(() => {
const id = setInterval(() => {
setSecondsOpen((seconds) => seconds + 1); // always the latest, by construction
}, 1000);
return () => clearInterval(id);
}, []);
Fix 3 — keep the latest value in a ref, when you want to read something current without re-running the effect:
const notesRef = useRef(notes);
notesRef.current = notes; // updated on every render
useEffect(() => {
const id = setInterval(() => saveDraft(candidateId, notesRef.current), 10000);
return () => clearInterval(id);
}, [candidateId]);
The interval is created once and keeps ticking on its own schedule, and each tick reads whatever is in the box right now. A ref is the same object across every render — that is its entire purpose, and chapter 8 is next.
One rule that saves enormous amounts of time: when the lint rule tells you a dependency is missing, it is right. react-hooks/exhaustive-deps is reporting a genuine stale-closure risk. Silencing it with a disable comment does not fix anything; it converts a warning into a bug that will surface weeks later as "the autosave sometimes saves the wrong thing". If the honest answer is "I do not want this effect to re-run when that changes", that is what fixes 2 and 3 are for.
Data fetching in an effect, and the race it hides
Back to the top of the file. The drawer needs the selected candidate's full submission, and the selection changes as the reviewer clicks around:
useEffect(() => {
let cancelled = false;
async function load() {
const res = await fetch(`/api/candidates/${candidateId}`);
const data = await res.json();
if (!cancelled) setDetail(data); // ignore a response we no longer want
}
load();
return () => { cancelled = true; };
}, [candidateId]);
Without that guard, here is what happens on a slow connection. The reviewer clicks Priya, whose submission is large. Impatient, they click the next candidate, whose submission is small and comes back fast. Its data renders. Then Priya's request finally resolves and overwrites it — and the drawer is now showing Priya's submission under the other candidate's name, with no error anywhere and nothing in the console.
That is a genuine race condition, not a theoretical one, and it is trivial to reproduce by throttling your network in DevTools.
The cancelled flag works because of the ordering rule from earlier: when candidateId changes, React runs the previous effect's cleanup before running the new effect. Each effect run has its own cancelled variable, in its own closure — the same capture behaviour that caused the autosave bug is what makes this fix airtight. Flipping the old one to true tells the old, still-pending request to shut up when it arrives.
AbortController goes one step further and cancels the request itself rather than discarding its result:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/candidates/${candidateId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setDetail)
.catch((err) => {
if (err.name !== "AbortError") setError(err); // an abort is not a failure
});
return () => controller.abort();
}, [candidateId]);
Note the catch: aborting makes the promise reject, and treating that rejection as an error would show the reviewer a failure message every time they clicked quickly.
Worth being honest about where this pattern sits today. Hand-written fetch-in-useEffect has to handle the race, loading state, error state, retries, caching and revalidation — and every component that fetches has to handle all of it again. That is why React Query, SWR and framework-level data loading exist, and why you will see less of this code in new production codebases; React 19's use hook reads a promise directly with Suspense handling the pending state, which moves the problem again. Understanding why the guard is necessary is still the point. Every one of those libraries is solving this exact race for you, and knowing that is the difference between using one and trusting one.