You might not need an effect
Most effects in real codebases are unnecessary and buggy. Learn the catalog of effects to delete — derived state, display transforms, prop-driven resets, caching, parent notifications, chained updates — and the one effect you must never delete by mistake.
Open almost any React codebase and you will find effects that exist to keep one piece of state in sync with another piece of state. A useEffect that recomputes a filtered list when the query changes. One that resets the form when the selected user changes. One that calls onChange after a state update lands. Each looks reasonable in isolation, and each is a bug waiting to fire — an extra render, a stale closure, a flash of wrong data, a sync that runs one tick too late.
The senior move is not writing better effects. It’s deleting them. An effect is a synchronization with a system outside React; if there’s no external system involved, the effect is almost always re-deriving data you already have, and re-deriving in an effect is the slow, racy way to do something render does for free. This lesson is the catalog of effects to remove — and the one you must not.
After this lesson you can recognize the six most common unnecessary-effect patterns — derived state, transforming data for display, resetting state on a prop change, caching expensive results, notifying a parent, and chaining state updates — and remove each using the correct tool (render-time computation, key, useMemo, the event handler). You can also tell these apart from a real external-synchronization effect, so you delete the buggy ones without deleting the one that’s actually doing its job.
If you can compute it from props and state during render, it is not state — and it must not live in an effect. The classic anti-pattern stores a derived value in useState and keeps it current with an effect. That’s two renders per change (set the source, effect runs, set the derived, render again), a window where the two disagree, and a whole dependency array to get wrong. The fix is to compute it inline while rendering.
// ❌ derived state synced by an effect: extra render, can go stale
function FullName({ first, last }: { first: string; last: string }) {
const [full, setFull] = useState("");
useEffect(() => { setFull(`${first} ${last}`); }, [first, last]);
return <h2>{full}</h2>;
}
// ✅ just compute it — always consistent, one render
function FullName({ first, last }: { first: string; last: string }) {
const full = `${first} ${last}`;
return <h2>{full}</h2>;
}The render is the synchronization. Any time you catch yourself writing setSomething inside an effect whose only inputs are other props/state, you almost certainly want a plain const.
Transforming data for display is the same anti-pattern with a list instead of a string — and the same fix. Filtering, sorting, mapping a fetched array into view-models: none of that touches an external system, so none of it belongs in an effect. Compute it in render. If — and only if — profiling shows the transform is genuinely expensive and runs on every unrelated render, wrap it in useMemo. useMemo is a render-time cache, not an effect; it never causes the double-render-then-flash that the effect version does.
// ❌ effect mirrors a derived list into state
const [visible, setVisible] = useState<Todo[]>([]);
useEffect(() => {
setVisible(todos.filter((t) => t.status === filter));
}, [todos, filter]);
// ✅ derive in render; memoize only if profiling says the filter is hot
const visible = useMemo(
() => todos.filter((t) => t.status === filter),
[todos, filter],
);Reach for useMemo because a measurement told you to, not reflexively. An un-memoized .filter() over a few hundred rows is fine, and a useMemo everywhere is just noise that hides the one that matters.
To reset state when a prop changes, give the component a key — don’t reset it from an effect. A common shape: a profile editor holds draft state, and an effect clears the draft whenever userId changes. That effect renders the old user’s draft for one frame before the reset lands, and it has to enumerate every piece of state to clear. React already has a primitive for “this is conceptually a different instance now”: change the key, and React unmounts the old subtree and mounts a fresh one with initial state. No effect, no stale frame, no enumeration.
// ❌ effect resets local state on identity change — flashes the old draft
function Editor({ userId }: { userId: string }) {
const [draft, setDraft] = useState("");
useEffect(() => { setDraft(""); }, [userId]); // also: forgot the other 3 fields
/* ... */
}
// ✅ key makes React remount a fresh instance — all state resets, atomically
function EditorBoundary({ userId }: { userId: string }) {
return <Editor key={userId} userId={userId} />;
}
function Editor({ userId }: { userId: string }) {
const [draft, setDraft] = useState(""); // starts clean per userId, no effect
/* ... */
}The key lives one level up, on the parent that decides identity. This is the single highest-leverage effect to delete: it removes an entire class of “why is the form showing the previous record’s data” bugs.
Notify the parent, run side effects, and chain updates inside the event handler — not in an effect that watches state. Two related smells live here. First, notifying a parent: an effect that calls onChange(value) after value changes fires on every cause of the change (including parent-driven ones) and lands a render late. Put the call in the handler that caused the change, where you already know the new value. Second, chained updates: setting state A in an effect that watches state B, which sets C in an effect that watches A, is a cascade of extra render passes that’s impossible to follow. Compute the whole next state in the handler and set it in one place.
// ❌ effect mirrors the change up to the parent — extra render, fires on every path
function Toggle({ onChange }: { onChange: (on: boolean) => void }) {
const [on, setOn] = useState(false);
useEffect(() => { onChange(on); }, [on]); // and onChange isn't even in deps
return <button onClick={() => setOn((v) => !v)}>{on ? "On" : "Off"}</button>;
}
// ✅ do both things in the one place that has the new value: the handler
function Toggle({ onChange }: { onChange: (on: boolean) => void }) {
const [on, setOn] = useState(false);
function handleClick() {
const next = !on;
setOn(next);
onChange(next); // parent learns immediately, in the same event
}
return <button onClick={handleClick}>{on ? "On" : "Off"}</button>;
}Event handlers run because the user did something; effects run after rendering, for any reason. When the cause is a specific interaction, the handler is the correct home — it’s earlier, it has the new value in hand, and it won’t fire on unrelated re-renders.
A search results component, before and after the cleanup. It fetches results for a query, exposes a “result count” to its parent, and resets its scroll/selection when the query changes. The first version expresses all three with effects.
// ❌ before: three effects, two of them unnecessary and buggy
function Results({ query, onCount }: { query: string; onCount: (n: number) => void }) {
const [items, setItems] = useState<Item[]>([]);
const [sorted, setSorted] = useState<Item[]>([]);
const [selected, setSelected] = useState<string | null>(null);
// (1) real: fetch from the server when the query changes
useEffect(() => {
let active = true;
fetchResults(query).then((r) => { if (active) setItems(r); });
return () => { active = false; };
}, [query]);
// (2) unnecessary: derived list mirrored into state
useEffect(() => { setSorted([...items].sort(byScore)); }, [items]);
// (3) unnecessary: notify parent + reset selection from an effect
useEffect(() => { onCount(sorted.length); }, [sorted]);
useEffect(() => { setSelected(null); }, [query]); // flashes old selection
return <List items={sorted} selected={selected} onPick={setSelected} />;
}Effect (2) is derivable, effect (3)‘s notify belongs where the data arrives, and the selection reset belongs to identity — a key. After the cleanup, exactly one effect remains, and it’s the only one that touches the outside world.
// ✅ after: one real effect; the rest become render + key
function Results({ query, onCount }: { query: string; onCount: (n: number) => void }) {
const [items, setItems] = useState<Item[]>([]);
useEffect(() => { // the ONE legitimate effect: server sync
let active = true;
fetchResults(query).then((r) => {
if (!active) return;
setItems(r);
onCount(r.length); // notify exactly when data arrives
});
return () => { active = false; };
}, [query]);
const sorted = useMemo(() => [...items].sort(byScore), [items]); // derive in render
return <List items={sorted} onPick={() => {}} />; // selection reset handled by key
}
// selection resets per query because identity changes → fresh instance
<Results key={query} query={query} onCount={setCount} />;Two effects deleted, one stale-frame bug and one fire-on-every-render bug gone, and the remaining effect reads as exactly what it is: “sync with the server when the query changes.” That legibility is the real payoff — the next reader can trust that an effect in this file means a real external boundary.
▸Common mistake
The dangerous over-correction is deleting a real external-synchronization effect because it superficially looks like derivable state. Subscribing to a store, attaching a DOM event listener, starting a timer, opening a WebSocket, or imperatively syncing a non-React widget (a map, a chart, a video player) are genuine effects: the data lives outside React and must be pulled in and torn down. The tell is the cleanup function — if the work needs a teardown (unsubscribe, clearInterval, close the socket), it is synchronization, not derivation, and the const/useMemo/key rewrites do not apply. Delete effects that re-derive what render can compute; keep effects that reach outside React’s world.
▸Why this works
Why is the effect version not just verbose but actually buggier? Because an effect runs after the render commits. So there is always at least one painted frame where the source state has updated but the derived state hasn’t — the stale-frame flash. There’s the double render (set source → commit → effect → set derived → commit again) that doubles work on a hot path. And there’s the dependency array, which silently goes stale when you forget a dep (like onChange) and produces wrong values with no error. Computing in render sidesteps all three: there is no “after”, no second commit, and no dependency list to desync.
A component subscribes to a browser online/offline event with addEventListener in a useEffect, stores the status in state, and removes the listener in the cleanup. A reviewer says 'you might not need an effect — derive it in render instead.' Who's right?
Most effects in real codebases are unnecessary, and unnecessary effects aren’t just clutter — they cause stale-frame flashes, double renders, and stale-closure bugs. The catalog to delete: derived state and display transforms (compute in render; reach for useMemo only when profiling demands it), resetting state on a prop change (give the subtree a key so React remounts a fresh instance), notifying a parent or chaining updates (do it in the event handler that already holds the new value). The single question that sorts them: does this effect talk to a system outside React? If no, it’s re-deriving data and a non-effect tool does it earlier and more reliably. If yes — network, DOM, subscriptions, timers, non-React widgets, anything with a real teardown — it’s synchronization, and you keep it. The senior skill is holding both edges: aggressively delete the derivable effects, and never mistake a genuine external-sync effect for one of them.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.