Effect-as-derived-state
The most common React anti-pattern: a useEffect whose only job is setState-from-props-or-state. It costs an extra render and shows a stale value for one tick. Delete it — derive in render, or reset with a key — but not if it syncs an external system.
There is one React anti-pattern you will find in almost every codebase you join, often dozens of times: a useEffect whose entire body is a setState, and whose dependency array is some other state or prop. It reads like the responsible thing — “when the input changes, keep the derived value in sync” — and it is the single most reliable source of off-by-one-render bugs in React.
The effect isn’t synchronizing with anything outside React. It’s copying React state into more React state, one render too late. That delay is not a corner case you can patch; it’s the defining behaviour of the pattern. The senior fix is almost never a better effect. It’s deleting the effect.
After this lesson you can spot an effect-as-derived-state at a glance — a useEffect whose body is only setState and whose deps are other state/props — and explain precisely why it’s wrong: the extra render and the one-tick window where the mirrored value is stale. You can remove it two ways: compute the value during render when it’s a pure function of inputs, or reset state with a key when a prop change should blow it away. And, critically, you can tell this anti-pattern apart from a genuine external-synchronization effect, so you don’t over-correct and delete an effect that’s actually doing its job.
The shape to recognize: an effect whose only job is setState from other state or props. No subscription, no fetch, no DOM API, no timer — just one piece of React state being mirrored into another. The moment you see this, the effect is a candidate for deletion.
function ProfileForm({ user }: { user: User }) {
const [firstName, setFirstName] = useState(user.firstName);
const [fullName, setFullName] = useState("");
// anti-pattern: this effect's only job is setState-from-state
useEffect(() => {
setFullName(firstName + " " + user.lastName);
}, [firstName, user.lastName]);
return <input value={firstName} onChange={(e) => setFirstName(e.target.value)} />;
}fullName is not independent state — it is fully determined by firstName and user.lastName. Storing it separately and re-syncing it in an effect is the bug. The test: if you can write the value as a pure expression of props and current state, it is derived, not state.
Why it’s wrong, mechanically: an extra render and a stale tick. React renders with the new firstName, then paints, then runs the effect, which calls setFullName, which schedules a second render. Between those two renders there is a frame where firstName is “Taylor” but fullName still reads “Jordan Smith”. For a fraction of a second the UI is internally inconsistent — a real, observable flash of stale data, plus double the renders.
// render 1: firstName="Taylor", fullName="Jordan Smith" ← stale, painted
// effect runs → setFullName("Taylor Smith")
// render 2: firstName="Taylor", fullName="Taylor Smith" ← finally correctIt gets worse the moment a second derived value depends on the first, because each effect adds another tick of delay, and the deps arrays start to drift out of sync with each other. This is not a performance nitpick — it is a correctness bug that happens to also be slow.
Fix #1 — compute it during render. No state, no effect. A value that is a pure function of props and state should be calculated inline. There is no stale tick because there is no second render: the value is always derived from the inputs that produced the current render.
function ProfileForm({ user }: { user: User }) {
const [firstName, setFirstName] = useState(user.firstName);
const fullName = firstName + " " + user.lastName; // derived, always fresh
return <input value={firstName} onChange={(e) => setFirstName(e.target.value)} />;
}Two useState became one, the effect is gone, and the inconsistency window is gone with it. If the computation were genuinely expensive (sorting ten thousand rows on every keystroke), you’d wrap it in useMemo — but useMemo is still render-time derivation, not an effect. Reach for it only when profiling shows the cost is real; for string concatenation and ordinary filters, plain render-time computation is correct and cheaper than the memo bookkeeping.
Fix #2 — when a prop change should reset internal state, use a key, not an effect. A close cousin of the anti-pattern is “when userId changes, clear the draft and selection.” People reach for an effect that resets several useStates. That has the same stale-tick problem and is easy to get partially wrong. Instead, give the subtree a key tied to the identity that should reset it — React unmounts and remounts it, resetting all its state in one shot, during the same commit.
// anti-pattern: effect mirrors prop change into a state reset (stale tick + partial-reset bugs)
useEffect(() => { setDraft(""); setSelection(null); }, [userId]);
// fix: identity change → remount → all internal state resets atomically
<ProfileEditor key={userId} userId={userId} />The key approach scales: it resets all state in the subtree, including state you’d forget to reset by hand, with no extra render and no flash. Use it whenever “this prop changed, so start fresh” is the real intent.
A list with a filter — the anti-pattern and its removal, side by side. A product list filters by a search query. The first version mirrors the filtered result into state via an effect.
function ProductList({ products }: { products: Product[] }) {
const [query, setQuery] = useState("");
const [visible, setVisible] = useState(products);
// anti-pattern: visible is derived, but stored + synced in an effect
useEffect(() => {
setVisible(products.filter((p) => p.name.includes(query)));
}, [query, products]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>{visible.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
</>
);
}Type a character and the list shows the previous filter result for one frame before the effect catches up. If products arrives from a fetch and changes, there’s a render where visible is the old products against the new query. The fix deletes the state and the effect entirely:
function ProductList({ products }: { products: Product[] }) {
const [query, setQuery] = useState("");
const visible = products.filter((p) => p.name.includes(query)); // derived in render
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>{visible.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
</>
);
}Now visible can never disagree with query or products, because it is recomputed from them on every render — no second render, no stale frame. One useState and one effect were pure overhead. If the filter were genuinely expensive you’d reach for useMemo(() => products.filter(...), [products, query]), but that is still derivation in render, never an effect.
▸Why this works
Why does deriving in render feel “wasteful” to people who reach for the effect? Because they’re afraid of recomputing on every render. But recomputation is what render is — UI = f(state) means the component re-runs top to bottom every render anyway. A plain .filter() or string concat is cheaper than the cost the effect adds: an extra component render, a reconciliation pass, and the diff. You are not saving work by mirroring into state; you are adding a render and a window of wrong data to avoid work that wasn’t expensive in the first place.
▸Common mistake
The dangerous over-correction: deleting an effect that is genuinely synchronizing with something outside React. “Effect-as-derived-state” applies only when the effect’s input and output are both React state/props. An effect that subscribes to a WebSocket, reads localStorage, syncs to document.title, sets up a ResizeObserver, or wires up a non-React widget is doing real external synchronization — it cannot be replaced by render-time computation, because render must stay pure and side-effect-free. The tell is the source: if the value comes from outside React (the network, the DOM, a browser API, another tab), keep the effect (or prefer useSyncExternalStore for external stores). If both ends are React state, delete it. Mistaking the second kind for the first is how a “cleanup” PR removes the only thing keeping the tab title in sync.
A component has `const [total, setTotal] = useState(0)` plus a `useEffect(() => setTotal(items.reduce((s, i) => s + i.price, 0)), [items])`. What is the precise problem, and the fix?
Effect-as-derived-state is the most common React anti-pattern: a useEffect whose only job is setState from other state or props. It is wrong by construction — React commits a render with the old mirrored value, then the effect runs and schedules a second render to fix it, leaving a one-tick window where the UI is internally inconsistent, plus the cost of the extra render. The fix is almost never a better effect; it’s no effect. When the value is a pure function of inputs, compute it during render (and use useMemo only when profiling proves the cost is real). When a prop change should reset internal state, use a key so React remounts the subtree and resets all of it atomically. The one thing you must not do is over-correct: an effect whose source is outside React — a socket, the DOM, localStorage, another tab — is genuine synchronization and cannot be derived in render. The discriminator is always the source. React state in, React state out: delete it. Outside world in: keep it.
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.