A Mental Model for useEffect
Effects synchronize your component with an external system; most of what you reach for them isn't a synchronization problem at all.
The phrase "useEffect runs after render" is true and almost useless. The model that actually predicts behavior is this: an effect synchronizes your component with some external system, and React re-runs it whenever the inputs to that synchronization change. Render computes the UI; effects reach outside React to keep something else — a subscription, a DOM node, a network connection — in step with the latest props and state.
1. Effects are synchronization, not lifecycle
If you still think in terms of componentDidMount / componentDidUpdate, you will write effects that fight React. The class-component framing asks "what should happen at this moment in time?" The effect framing asks "what external thing should currently be true, given these values?" The difference is not pedantic — it changes how you decide what goes in the dependency array and what goes in cleanup.
Concretely, an effect has three parts: a setup function (connect, subscribe, attach), an optional cleanup function it returns (disconnect, unsubscribe, detach), and a dependency array describing the values the synchronization depends on. React runs setup after committing to the DOM, runs cleanup before the next setup (and on unmount), and decides whether to re-synchronize by comparing dependencies.
useEffect(() => {
const connection = chat.connect(serverUrl, roomId); // setup
return () => connection.disconnect(); // cleanup
}, [serverUrl, roomId]); // dependencies
Read that as: "while this component is mounted, there should be exactly one live connection to roomId on serverUrl." When roomId changes, React tears down the old connection and opens a new one. You never wrote "on update" logic — you described a desired state and let React reconcile transitions.
2. The dependency array is a correctness contract, not an optimization
The most common misconception is that dependencies exist to "skip re-running for performance." They exist to keep the effect consistent with the values it closes over. The rule is mechanical and non-negotiable: every reactive value (props, state, and anything derived from them) used inside the effect must be in the array. The linter (react-hooks/exhaustive-deps) enforces this, and you should treat fighting it as a code smell rather than a nuisance.
[a, b]— re-run setup (after cleanup) wheneveraorbchanges byObject.is.[]— run setup once after mount, cleanup once on unmount. This is a claim that the effect depends on nothing reactive. If it secretly does, you've created a stale closure.- no array — run after every render. Almost always a bug; usually means the work doesn't belong in an effect at all.
The crisp answer: the dependency array doesn't control when the effect is allowed to run, it declares what the effect's result depends on. If you find yourself wanting to lie to the array to control timing, the design is wrong — you likely need an event handler, a ref, or to lift the value out of the effect entirely.
3. Cleanup, and exactly when it runs
Cleanup is the part engineers under-use. The returned function runs in two situations: before the effect re-runs with new dependencies, and when the component unmounts. The sequence on a dependency change is always cleanup-then-setup — old subscription torn down before the new one is created, so you never leak two live connections.
The mental test for whether you need cleanup: "if this effect ran twice in a row without cleanup, would something accumulate or go wrong?" Subscriptions, timers, event listeners, and in-flight requests all answer yes. Cleanup is what makes the effect idempotent over time — re-running setup-then-cleanup returns the external system to a known-good single state.
useEffect(() => {
const id = setInterval(() => tick(), 1000);
return () => clearInterval(id); // without this, intervals stack up
}, []);
4. StrictMode double-invoke: mount, cleanup, mount
In development, React 18 and later, when the tree is wrapped in <StrictMode>, intentionally runs each effect twice on mount: setup, then cleanup, then setup again. This is dev-only behavior; it does not happen in production builds. React is stress-testing your assumption that setup and cleanup are symmetric. If your effect survives a setup → cleanup → setup cycle with no observable difference, it is resilient to the real-world cases that also trigger remount — Fast Refresh during development, and features that unmount and later remount a subtree while preserving its state (for example, the <Activity> API and back/forward caches).
The right response to "my fetch fires twice in dev" is never to disable StrictMode or add a guarding ref. It's to add the cleanup that should have been there anyway. A correctly written effect is invisible to the double-invoke; a buggy one is exposed by it. That is the entire point.
// The double-invoke leaves exactly one live connection: cleanup
// disconnects the first connection before the second setup runs.
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
5. The stale-deps trap
The classic failure is reading a reactive value while declaring []. The effect closes over the value from the render in which it was created and never sees updates:
// BUG: count is frozen at its mount value
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // lying: this effect does depend on count
You have two honest fixes. Add count to the deps (the interval is recreated each tick — correct but wasteful), or remove the dependency by using the functional updater so the effect no longer reads count at all:
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []); // honest now: the effect truly depends on nothing reactive
The general principle: when the linter demands a dependency you don't want to react to, the answer is to remove the dependency from the effect's body — via a functional updater, a ref for the latest non-reactive value, or the useEffectEvent Hook (stabilized in React 19.2; experimental before that) for logic that should read fresh values without re-triggering the effect — not to silence the lint.
6. Fetch with cleanup (the race-condition-safe version)
Data fetching in an effect is legitimate, but naive versions race: if query changes faster than responses return, an older response can overwrite a newer one. Cleanup with an ignore flag fixes it.
function useResults(query: string) {
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
let ignore = false;
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((r) => r.json())
.then((data) => {
if (!ignore) setResults(data);
})
.catch((err) => {
// Aborts are expected on cleanup; ignore late errors too.
if (!ignore && err.name !== "AbortError") {
console.error(err);
}
});
return () => {
ignore = true; // ignore a late response from this run
controller.abort(); // and cancel the request if still in flight
};
}, [query]);
return results;
}
The ignore flag is the load-bearing part: even without AbortController, it guarantees only the most recent run's response is applied. This pattern also survives StrictMode cleanly — the first setup's request is aborted and ignored by the first cleanup. In real apps, prefer a framework or library (TanStack Query, RTK Query, or Server Components) over hand-rolling this, but you should be able to write it from memory.
7. The big one: when you do NOT need an effect
Most effects in real codebases shouldn't exist. Effects are an escape hatch to step outside React; if you're only computing data for the render or responding to a user action, you never left React, so there's nothing to synchronize. Three rules cover the vast majority of cases:
- Derived state → compute during render. Don't mirror props or state into more state with an effect. It adds an extra render pass and the copy can fall out of sync with its source.
- Responding to a user interaction → event handler. "When the user submits, POST the form" is caused by an event, not by rendering. Put it in
onSubmit. - Expensive transforms → compute in render, memoize if measured slow. Reach for
useMemo, not an effect that writes to state.
Here is the anti-pattern and its rewrite:
// DON'T: effect to derive state
const [firstName, setFirstName] = useState("Taylor");
const [lastName, setLastName] = useState("Swift");
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(firstName + " " + lastName); // extra render, can go stale
}, [firstName, lastName]);
// DO: derive during render
const fullName = firstName + " " + lastName;
// DON'T: effect reacting to a click via state
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) post("/api/order", cart);
}, [submitted]);
// DO: the click is the cause — handle it where it happens
function handleSubmit() {
post("/api/order", cart);
}
The heuristic: "Is this caused by a particular interaction, or by the component simply being displayed?" Interactions belong in event handlers. Only "being displayed and needing to match an external system" belongs in an effect. And with React Server Components, a lot of data loading moves out of effects entirely — fetched on the server during render, with effects reserved for genuinely client-side, external synchronization.
Rules of thumb
- An effect synchronizes with an external system; if there's no external system, you probably don't need one.
- The dependency array is a correctness contract — list every reactive value the body reads, and never lie to the linter.
- Write cleanup so
setup → cleanup → setupis a no-op; if it is, StrictMode's dev-only double-invoke is invisible to you. - Fix stale closures by removing the dependency from the body (functional updater,
ref, oruseEffectEventin React 19.2+), not by emptying the array. - Derived data goes in render, interaction logic goes in event handlers — reserve effects for the few things that actually live outside React.