The useEffect Chain Reaction: Derived State and the Effects You Don't Need
There's a React anti-pattern so common it feels like idiom: something changes, so you write a useEffect to update some state in response. One effect is fine. But effects that set state which other effects depend on form chains — and chains of effects are how a click becomes eleven renders, a flash of stale UI, and a bug report that starts with "sometimes."
The chain, reconstructed
Simplified from the real code, but structurally faithful:
function FilterPanel({ region, dateRange }: Props) {
const [stores, setStores] = useState<Store[]>([]);
const [validStores, setValidStores] = useState<Store[]>([]);
const [selection, setSelection] = useState<string[]>([]);
const [query, setQuery] = useState<AnalyticsQuery | null>(null);
useEffect(() => { // 1: region → stores
fetchStores(region).then(setStores);
}, [region]);
useEffect(() => { // 2: stores → validStores
setValidStores(stores.filter((s) => isOpenDuring(s, dateRange)));
}, [stores, dateRange]);
useEffect(() => { // 3: validStores → fix selection
setSelection((sel) => sel.filter((id) => validStores.some((s) => s.id === id)));
}, [validStores]);
useEffect(() => { // 4: everything → query
setQuery(buildQuery(region, dateRange, selection));
}, [region, dateRange, selection]);
// ...and effect 5 in a child fired the fetch when query changed
}
Each effect is individually defensible — that's what makes the pattern survive code review. The composite is the problem:
- Render storms. Each effect runs after a committed render, sets state, and causes another. Five effects → up to six renders per input change, more when they interleave.
- Stale intermediate frames. Between effects 1 and 3, the UI commits with new stores but old selection. Users saw those frames as flicker — charts briefly rendering deselected stores.
- Ordering bugs. The CSV-export mismatch: export read
query(effect 4's output) while a chain was mid-flight — new region, old selection. "Sometimes wrong numbers" was literally a race between the user's click cadence and the chain's settling time.
The unwinding: most state is math
The question that dissolves the chain: can this value be computed from things I already have? If yes, it isn't state — it's a derivation, and it belongs in render:
function FilterPanel({ region, dateRange }: Props) {
const [selection, setSelection] = useState<string[]>([]);
const { data: stores = [] } = useQuery({
queryKey: ["stores", region],
queryFn: () => fetchStores(region),
});
// ✅ was effect 2 — now just math, always consistent with inputs
const validStores = useMemo(
() => stores.filter((s) => isOpenDuring(s, dateRange)),
[stores, dateRange]
);
// ✅ was effect 3 — derive the *effective* selection instead of correcting stored state
const effectiveSelection = selection.filter((id) =>
validStores.some((s) => s.id === id)
);
// ✅ was effect 4 — a query description is pure math too
const query = buildQuery(region, dateRange, effectiveSelection);
// ...
}
Three effects deleted. Note the subtle move on selection: instead of storing a corrected selection (which requires an effect to watch for invalidation), we store the raw user intent and derive the effective value. Storing intent and deriving consequences is the pattern that kills most "sync state with other state" effects. There's a bonus: if the user switches back to the previous region, their old selection is still there — the "correction" never destroyed data.
Render-time derivation cannot present a stale frame: every commit is consistent with its inputs, by construction. Eleven renders became two (one for the click, one when the store fetch resolved). The flicker was unrepresentable, and the CSV export — now reading the same derived query the charts used — could no longer disagree with them.
The effects that remain (and the ones that were never effects)
After unwinding, ask what's left. Legitimately effects: synchronizing with external systems — subscriptions, imperative DOM APIs, analytics beacons. Our fetches moved to a query library (render-declarative, not effect-imperative). Two common stragglers deserve their named replacements:
"Reset state when a prop changes" — that's key, not an effect:
<FilterPanel key={region} region={region} /> // remounts with fresh state, zero effects
"Respond to a user action" — that's the event handler. If the code runs because the user clicked, it belongs in the click handler, not in an effect watching state the handler set:
// ❌ setExporting(true) in handler + useEffect(..., [exporting]) to do the export
// ✅ the handler just... exports
async function onExportClick() {
const blob = await exportCsv(query);
downloadBlob(blob, `analytics-${region}.csv`);
}
The dashboard's filter panel ended the audit with exactly one effect (an analytics beacon). The client asked whether removing code was really what they'd hired a consultant for. Eleven renders to two, and the un-reproducible bug became un-writable — best deletion-to-value ratio of my year.