useEffect Deep Dive: Synchronization, Not Lifecycle
What you'll learn
- The mental model that actually predicts behavior: effects synchronize your component with something outside React
- Exactly when effects and cleanups run relative to commit and paint
- How the dependency array really works, element by element
- Three complete real-world patterns: subscription, timer, and fetch with race protection
- Cleanup ordering when parent and child effects re-run together
- When you shouldn't use an effect, and what to do instead
Most people meet useEffect as "componentDidMount plus componentDidUpdate plus componentWillUnmount". That framing produces effects everywhere and bugs to match. Here is the reframe this chapter is built on: an effect says "keep something outside React in sync with my state and props." Subscriptions, timers, the DOM, networks, those live outside. If there's no outside system involved, you probably don't need an effect at all. Let's make the model precise.
The model: render, commit, paint, effect
One render pass, in order:
- Render: React calls your component and computes the new description.
- Commit: React writes the changes into the host environment (the DOM).
- Paint: the browser draws the updated UI. The user can already see it.
- Effect: your effect function runs, after the screen is up to date.
On later renders where the dependencies changed: cleanup the old effect, then run the new one. On unmount: the final cleanup runs, and the cell is discarded with the node.
Jargon: "commit". The moment React applies a render's changes to the host environment. Rendering is calculation; committing is mutation.
Jargon: "cleanup". The function you return from an effect. React runs it before re-running the effect and at unmount, so every setup has a matching teardown.
Why after paint? So synchronization work can't block the user from seeing updates. (The rare before-paint variant, useLayoutEffect, is the next chapter.)
Deps decoded
The dependency array declares what your effect synchronizes with:
useEffect(() => {
console.log('effect ran');
}); // no array: sync after EVERY commit
useEffect(() => {
console.log('effect ran');
}, []); // empty: sync once per mount, cleanup at unmount
useEffect(() => {
console.log('effect ran');
}, [roomId]); // sync at mount, and after any commit where roomId changed
What happens: after each commit, React compares the new array with the stored one element by element using Object.is, the same comparison as the state bailout. If any element differs (or there is no array), the old cleanup runs and the new effect fires. The array literal is recreated every render; only the elements matter.
Example A: a window resize subscription
import { useEffect, useState } from 'react';
function WindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
console.log('subscribed');
return () => {
window.removeEventListener('resize', handleResize);
console.log('unsubscribed');
};
}, []);
return <p>Window is {width}px wide</p>;
}
What happens:
- Mount → commit → paint → effect: subscribe to
resize. Log:subscribed. - Each resize fires the handler →
setWidth→ re-render. Deps[]never change, so React does not unsubscribe and resubscribe, the effect's whole job, the listener, simply persists. - Unmount → cleanup: the listener is removed. Log:
unsubscribed. No leak.
Example B: an interval: and the leak cleanup prevents
import { useEffect, useState } from 'react';
function Clock() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(s => s + 1); // functional update: no stale closure
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{seconds}s</p>;
}
What happens:
- Mount: one interval starts; the cleanup holds its id.
- Every second, the updater ticks the count, note
s => s + 1, so the empty deps never cause a stalesecondsread (the deep dive chapter's lesson, paying rent). - Unmount:
clearIntervalstops the timer. Tie the timer's lifetime to the component's lifetime, that's the whole pattern.
Now delete the cleanup and navigate away and back five times: five intervals are running, each ticking state for a component that's gone, slowing the page and leaking memory. In development, StrictMode deliberately mounts, unmounts, and remounts your components once to surface exactly this class of bug, a missing cleanup shows up as doubled intervals on the very first render. There's a full StrictMode section later in the series; for now: cleanup isn't optional, it's half the effect.
Example C: fetch with an ignore flag for race conditions
import { useEffect, useState } from 'react';
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
let ignore = false;
async function run() {
const response = await fetch(
'/api/search?q=' + encodeURIComponent(query)
);
const data = await response.json();
if (!ignore) {
setResults(data);
}
}
run();
return () => {
ignore = true;
};
}, [query]);
return (
<ul>
{results.map(r => (
<li key={r.id}>{r.title}</li>
))}
</ul>
);
}
What happens, the user types "re", then quickly "rea":
- The effect for
"re"runs; request A is in flight. querychanges → cleanup for the"re"effect flips itsignoretotrue→ the effect for"rea"runs; request B is in flight.- Networks are unordered: suppose A resolves last. Its
ignoreistrue, so its stale data is dropped. - B resolves with
ignore = false→setResultsruns. Last write wins, and the "last write" is the live effect, not the last response.
Each effect instance owns its flag; cleanup flips it; only the live effect writes. This is the race-condition pattern for fetch-in-effect.
Cleanup ordering across the tree
When a parent and child both have effects and a shared value changes, in what order do things run?
import { useEffect, useState } from 'react';
function Child({ room }) {
useEffect(() => {
console.log('Child effect', room);
return () => console.log('Child cleanup', room);
}, [room]);
return <p>Room {room}</p>;
}
function Parent() {
const [room, setRoom] = useState(1);
useEffect(() => {
console.log('Parent effect', room);
return () => console.log('Parent cleanup', room);
}, [room]);
return (
<div>
<button onClick={() => setRoom(r => r + 1)}>Next room</button>
<Child room={room} />
</div>
);
}
Click once, and the console shows:
Child cleanup 1
Parent cleanup 1
Child effect 2
Parent effect 2
What happens:
- The commit for
room = 2lands and paints. - All cleanups from the previous commit run first, child before parent, so no new effect ever starts while an old subscription is still active.
- Then all new effects run, again child before parent, so a parent's effect never observes a child in a half-cleaned state.
One sentence to memorize: all cleanups run before any new effects.
You might not need an effect
Three everyday misuses, and their better homes:
Derived state, compute during render:
import { useState } from 'react';
function NameBadge() {
const [first, setFirst] = useState('Ada');
const [last, setLast] = useState('Lovelace');
const fullName = first + ' ' + last; // derived during render, no effect
return (
<div>
<input value={first} onChange={e => setFirst(e.target.value)} />
<input value={last} onChange={e => setLast(e.target.value)} />
<p>{fullName}</p>
</div>
);
}
What happens: fullName is recalculated on every render from the latest values, always in sync by construction, no second state to drift. An effect that copies props/state into more state only adds a render and a bug surface.
Resetting state when a prop changes, use key:
<UserProfile key={userId} userId={userId} />
What happens: when userId changes, React sees a different key at that position, discards the old instance (node, cells, state), and mounts a fresh one. Clean reset, no effect watching the prop.
Responding to an event, put it in the handler:
import { useState } from 'react';
function NewsletterForm() {
const [email, setEmail] = useState('');
function handleSubmit() {
// right place for "do this when the user submits"
sendToServer(email);
alert('Subscribed: ' + email);
}
return (
<div>
<input value={email} onChange={e => setEmail(e.target.value)} />
<button onClick={handleSubmit}>Subscribe</button>
</div>
);
}
What happens: the action runs exactly once per user intent. The effect version, watching a submitted flag, fires on re-renders, remounts, and StrictMode probes instead.
Effects answer "what should stay true about the outside world while I'm on screen?", not "what should happen when the user clicks?"
When effects ARE right
Effects are the boundary between React and everything else: subscriptions, timers, manually managed DOM widgets (maps, editors, charts), network synchronization, document.title, analytics beacons. The test: if you remove React from the sentence, does the thing still exist? window, setInterval, WebSocket, fetch, yes, they live outside. A full name string, no, that's render's job.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception:
useEffectis a lifecycle-method replacement. Reality: it's a synchronization declaration; lifecycle thinking produces flag-watching effects and double-fires. - Misconception: The effect runs before the user sees the screen. Reality: it runs after commit and paint;
useLayoutEffect(next chapter) is the before-paint variant. - Misconception:
[]means "on mount" by magic. Reality: it means "nothing to track", mount is simply the only commit guaranteed to happen. - Misconception: The deps array is a performance hint. Reality: it's a correctness declaration of the values you synchronize with; missing deps are stale-closure bugs.
- Misconception: Cleanup only matters at unmount. Reality: it runs before every re-run, that's how effects avoid stacking subscriptions.
- Misconception: More effects means a more reactive app. Reality: most beginner effects should be render-time computation, a
key, or an event handler.
Why it works this way
- After-paint timing keeps rendering unblocked. Synchronization waits its turn instead of delaying what the user sees.
- Cleanup-before-rerun makes each effect self-contained. Setup and teardown travel together, so pairing is impossible to forget.
Object.isper dep matches the state bailout. One comparison rule covers both systems, less to learn, fewer surprises.- All cleanups before all effects preserves tree-wide invariants. Nothing new starts while something old is still subscribed.
- StrictMode's double-mount in development exists to prove your cleanup discipline. The effect model assumes setup and teardown are symmetric.
Try it yourself
- Build
WindowSize. Resize, then unmount it and confirmunsubscribedlogs. Now remove the cleanup, add a log inside the handler, remount a few times, and count duplicate listeners. - Build
Clockwith aconsole.log('tick')inside the interval. Comment out the cleanup, remount repeatedly, and watch the ticks multiply. - Throttle your network in DevTools, type fast in
SearchResults, and log request start and end. Verify theignoreflag drops the stale response, then remove it and watch stale data win. - Run the
Parent/Childordering demo. Write down the four expected log lines before clicking; compare with reality.
Recap
- An effect means: keep an outside system in sync with these values. It is not a lifecycle.
- Order: render → commit → paint → effect. Deps change → cleanup old, then run new. Unmount → final cleanup.
[]syncs at mount only, no array syncs after every commit,[x]syncs whenxchanges,Object.isper element.- Subscriptions and timers always pair setup with cleanup; StrictMode's dev double-mount exists to prove it.
- Fetch effects need an
ignoreflag (or cancellation) to survive races: the live effect writes last. - Across the tree, ALL cleanups run before ANY new effects, child before parent in each phase.
- Derived state, prop-driven resets, and event responses don't belong in effects, render,
key, and handlers cover them.