Anti-Patterns At Scale
The recurring React bugs are a short list, and each one is a mechanism from the earlier parts applied where another was needed. This part is effect chains frame by frame, derived state and copied props, prop drilling and context overuse with the composition fix, premature memo and the volume problems memo cannot fix, and a catalogue by symptom with the Profiler signature of each.
Effect chains
"Selecting a country flickers through three wrong states before settling. The code is four small useEffects. What is wrong with small effects?"
Nothing is wrong with small effects that synchronise with the outside world. These four synchronise state with other state, and each one runs after a paint the user has already seen. One action becomes four renders and three frames of inconsistent UI, and the logic that relates country to states to selection to estimate is spread across four places with dependency arrays as the only documentation. The fix is to compute the derived values during render (one render, consistent by construction) and to do action-driven work in the handler.
- An effect whose body is only
setStateof something computable from its dependencies: a derivation. Replace with a variable in render,useMemoif expensive. - An effect that responds to a user action ("when the selection changes, send analytics / reset the form / open the panel"): a handler. Do it where the action happened.
- An effect that resets state when a prop changes: a key. Remount with
key={prop}and the state starts fresh with no wrong frame. - An effect that adjusts state when another state changes: either derive, or use the "set state during render" form (
if (a !== prevA) { setPrevA(a); setB(…) }), which React re-renders before commit, so no frame is wrong. - An effect that truly synchronises (a subscription, a fetch, a DOM measurement, a document title): keep it, name it, give it a cleanup.
- Each link in the chain is a render, a commit, a paint, and a passive-effects task: 10 to 30 ms on a phone. A four-link chain is a visible stutter and three wrong frames.
- Each link runs on a Default lane after the sync render, so an interaction's INP (the browser course part 12) includes the chain if the user is waiting on the final state.
- The Profiler shows it as several commits per interaction, each "hooks changed" on the same component.
Derived state in state, and copied props
// derived state in state: two sources of truth and the effect that fails to keep them equal
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
useEffect(() => { setTotal(items.reduce((s, i) => s + i.price, 0)) }, [items]) // one render shows items with the OLD total
// fix: const total = items.reduce(…) (or useMemo for big lists). total cannot be wrong because it does not exist separately.
// the variant with props: copying a prop into state
function Editor({ initialText }) { const [text, setText] = useState(initialText) } // fine: an initial value, by design
function Label({ text }) { const [t, setT] = useState(text); useEffect(() => setT(text), [text]) } // wrong: a prop mirrored into state via an effect
// the Label renders the old prop for one frame on every change. just use the prop. if it must be editable, it is an initial value (first form), keyed to reset.
// the variant with "previous value": useEffect(() => { if (prev !== value) … }, [value]) with a ref
// often a derivation (compare in render), sometimes a real "on change" side effect (keep the effect, name it), rarely a job for getDerivedStateFromProps-style
// "adjust state while rendering": if (value !== prevValue) { setPrevValue(value); setDerived(…) } inside render: React re-renders immediately, before commit. no wrong frame.
// the variant with fetching: useEffect(() => { fetch(…).then(setData) }, [id]) → part 8: a query cache- State is what cannot be computed. Everything else is a variable in render. Storing a computed value creates a second source of truth and requires something (an effect, a handler) to keep them equal; that something is where the bug lives.
- A prop is not an initial value unless you mean it.
useState(prop)reads the prop once; later changes do not propagate. If that is intended (an editable draft seeded from the prop), key the component to the prop's identity so a new entity gets a fresh draft. If it is not intended, use the prop directly. - Expensive derivations get
useMemo, not state. The memo recomputes when inputs change, in the same render, with no wrong frame. - "Previous value" logic is usually a derivation (compare in render), occasionally a real side effect on change (an effect with a clear name), and rarely a state adjustment during render.
- Filtered or sorted copies in state:
[visible, setVisible]kept in sync with[items]and[filter]. Derive. - Form validity in state:
[isValid, setIsValid]updated in an effect from values. Derive. - Totals, counts, flags: all derivations.
- Fetched data in state after a query cache has it: two caches. Read from the cache.
- Server data copied into a reducer "so the store has everything": the same two caches, at scale (part 7).
Prop drilling and context overuse
// prop drilling: the honest version, the context version, and the composition version
// drilling: Page → Section → Card → Row → Button, five levels of `onSelect` that only Button uses
<Page onSelect={…}> → <Section onSelect> → <Card onSelect> → <Row onSelect> → <Button onClick={onSelect}>
// not wrong: it is explicit and type-checked. it hurts when the intermediate components are generic layout and the prop is one of twelve.
// context: for a value that is "ambient" to a subtree (the selection, the current user, the theme). the intermediates stop caring.
const Selection = createContext(null); <Selection value={{ selected, select }}>…<Button onClick={() => select(id)} />
// right when the value is dependency-like and changes rarely; wrong when it is per-keystroke state (every consumer renders: part 2)
// composition: pass the component, not the props. the Page renders the Button and gives it to Section as a child.
<Section actions={<Button onClick={onSelect} />}>…</Section> // Section renders {actions} in its slot; it never sees onSelect
// the intermediates become layout; the data lives where it is used; nothing is drilled. this is the fix most of the time.
// the tell for which you have: if the intermediate components would be the same with or without the prop, it is drilling; compose or context.
// if the intermediates transform the prop, it is data flow; keep it explicit.- What it is: a value passed through components that do not use it, to reach one that does. Explicit and typed, so not a bug; a smell when the intermediates are generic layout and the prop count grows.
- The composition fix: render the consumer where the data is and pass the element down as children or a slot. The intermediates become layout that renders what it is given. This removes most drilling without introducing any shared state.
- The context fix: for ambient, rarely changing values (user, theme, locale, a client). A context per concern.
- The store fix: for shared, frequently changing state read in many places (part 7).
- The tell: if removing the prop would leave the intermediate components unchanged, compose or use context; if the intermediates transform it, it is data flow and belongs as props.
- Frequently changing state in context: every consumer renders per change (part 2). A form's values, a cursor, a scroll position, a live counter in context is a tree-wide render per tick.
- One context for everything: an
AppContextwith user, cart, theme and settings couples unrelated consumers; a theme change renders the cart. - A value object rebuilt each render:
value={{ a, b }}renders consumers even when a and b are unchanged.useMemoit. - Context as a bus: a context used to call functions across the tree (an event emitter in disguise). The functions are stable; the pattern is fine; put the functions in their own context so consumers that only call do not render on state changes.
- The fixes: split by change rate; memoise values; separate state and dispatch contexts; move hot state to a store with selectors; keep context for dependency injection and compound components.
Premature memo, and the volume problems memo cannot fix
// premature memo: what it costs and when it pays
const Row = React.memo(function Row({ item, onSelect }) { return <li onClick={() => onSelect(item.id)}>{item.name}</li> })
// cost per render attempt: shallowEqual over props (a few compares); the hook node for the memo. benefit: skipping Row's render when props are equal.
// Row's render is a few hundred nanoseconds. the compare is a few tens. the saving is real only if props ARE equal (stable onSelect) AND the parent
// re-renders often without changing items. with an inline onSelect at the parent, the compare always fails: pure cost.
// the order of operations for a slow list: 1. is the parent rendering too much? (colocate state). 2. is the list too long for the DOM? (virtualise).
// 3. are the rows' props stable? (useCallback the handlers; useMemo the derived arrays; or enable the Compiler). 4. THEN memo the rows.
// 4 before 1 is the anti-pattern: memo everywhere, parents still re-render everything, and now every component pays a compare.
// useMemo on cheap values: useMemo(() => a + b, [a, b]) costs more than a + b. memo computations that show in a profile, and references that feed memo'd children or deps.
// useCallback on every handler: harmless but noisy; the Compiler makes it moot. without the Compiler: useCallback only for handlers passed to memo'd children or used in deps.React.memo: a shallow compare per render attempt; pays when the compare passes often and the render it skips is non-trivial or has a big subtree. Fails to pay when props are unstable (inline functions, objects), when the component is cheap, or when the parent rarely renders.useMemo: a deps compare plus a slot; pays for expensive computations and for references that feed memo'd children or dependency arrays. Costs more than it saves fora + b.useCallback: only meaningful downstream; alone it is a hook node for nothing.- The Compiler makes most of this moot by generating the right memoisation; the remaining judgement is whether a given component's render is worth skipping at all, which it decides by caching everything cheaply.
- Measure: the Profiler's flame chart and "why". Which components, how often, why.
- Reduce what renders: colocate state; split context; stop the effect chain. This removes renders.
- Reduce what exists: virtualise long lists; uncontrol large forms; paginate. This removes work per render.
- Stabilise references:
useCallback/useMemoat the parent, or the Compiler. This makes bailouts possible. - Memo the boundaries:
React.memoon components that now receive stable props and have a subtree worth skipping. - Micro-optimise the render only if a specific component's own time is the cost (the JS course part 14).
- 5,000 DOM rows are slow to lay out and paint whatever React does. Virtualise (the algorithms course part 1) or paginate.
- A 60-field controlled form renders 60 fields per keystroke. Uncontrol it (part 9).
- A chart re-rendering 10,000 SVG nodes on hover. Canvas, or a hover layer separate from the data layer.
- A table recomputing a sort of 100k rows per render. Sort once per data change (
useMemoon the data, not per row).
The catalogue, by symptom
| Anti-pattern | What it looks like | Why it hurts (mechanism) | Fix | Part |
|---|---|---|---|---|
| Effect chain | Effects setting state from state; several commits per action | Each link is a Default-lane render after paint; intermediate frames are wrong | Derive in render; do action work in handlers; key to reset | P11, P4 |
| Derived state in state | A total, a filtered list, a validity flag in useState with an effect syncing it | Two sources of truth; one frame stale per change | Compute in render; useMemo if expensive | P11 |
| Prop copied into state | useState(prop) + an effect mirroring prop changes | A stale frame per change; the effect is a chain link | Use the prop; or key the component when it is an initial value | P11 |
| Fetch in useEffect | loading/data/error state per component | Waterfalls; no cache; races; piecemeal loading | Route loaders; a query cache; Suspense | P8 |
| State too high | Page-level state for a leaf input | Whole subtree renders per keystroke | Colocate; split draft from committed | P7, P10 |
| Context as a store | Hot state in a context; one context for all | Every consumer renders per change; no selectors | Split; memoise values; a store with selectors | P7, P2 |
| Prop drilling through layout | Props threaded through components that do not use them | Coupling and noise, not performance | Compose (pass elements); context for ambient values | P10 |
| Inner component definitions | A component declared inside another's render | New type each render → remount, state loss | Hoist to module scope | P2 |
| Index or random keys | key={i}, key={Math.random()} | Position pairing or remount every render | Stable ids from data | P2 |
| Unstable props to memo | Inline objects and functions into React.memo children | Shallow compare always fails; pure cost | useCallback/useMemo at the parent; the Compiler; or no memo | P2, P11 |
| Premature memo | memo everywhere before measuring | Compares and slots for no skipped work | Measure; fix renders first; memo boundaries last | P11 |
| Controlled everything | Every field controlled at the form level | A form render per keystroke | Uncontrolled; per-field subscriptions | P9 |
| Layout in useEffect | Measuring and setting state in a passive effect | Two paints: a flash | useLayoutEffect for measure-and-adjust | P1, P4 |
| Refs read in render | ref.current during render | Null on first render; unsafe under concurrency | Read in effects and handlers | P4 |
| Hand-rolled store subscriptions | useEffect + useState around an external store | Tearing under concurrent rendering | useSyncExternalStore | P5 |
| Environment reads in render | window, Date.now, localStorage during render | Hydration mismatch; tearing | Effects; uSES with a server snapshot | P0, P13 |
| Suspense boundary too high | One boundary around the page | Everything hides for the slowest query | Nest boundaries per region | P5 |
| No virtualisation | Thousands of rows in the DOM | Layout and paint cost, whatever React does | Windowing | Algorithms P1 |