You Might Not Need an Effect - discussion (2022-04-30)
This article keeps coming up in review comments at work, and I'm curious what people actually do in practice when they stop using effects for "sync".
What replaces the common patterns (derived values, resetting state on prop change, "fetch when X changes")? Do you ever keep an effect because it's just simpler, even if it feels a little "impure"?
Comments (14)
Back to latestThe big win for me was realizing half my effects were just computing derived state late.
Resetting state on prop change is the one that trips people up.
We handle it by making the boundary explicit (keyed remount or explicit "reset" action) so it's not hidden in timing.
The moment I stopped writing "sync" effects was when I started treating these as explicit boundaries:
tsx
function EditorRoute({ docId }: { docId: string }) {
return <Editor key={docId} docId={docId} />;
}
function Editor({ docId }: { docId: string }) {
const [draft, setDraft] = useState('');
// No effect that says: if docId changes, reset draft.
// The remount is the boundary.
return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />;
}
It's blunt, but it makes the reset deterministic and testable.
If it's really "syncing with something external", I keep the effect. If it's "syncing two pieces of app state", I try to delete it.
For "fetch when X changes": we moved it into the render contract and render signals for cache posture.
It removes the "why did it fetch twice" conversations.
This is the part I want to get to. Right now we still have effects that fetch after a state change and it feels… slippery.
Yeah. Once fetch is render-visible, it stops being "a thing that happens" and becomes "a thing the route promises".
Sometimes I keep an effect because it's pragmatic. The trick is making the behavior explainable (signals/tests) so it's not spooky.
Keyed remounts are underrated. It feels blunt, but it makes resets explicit and testable.
The funniest part is how many "effects" turn out to be "derived state" and disappear once you write the derived key once.
One heuristic we use in review: if the effect exists to "make render correct", that's a bug waiting to happen.
If it exists because the outside world needs a side effect (subscription/DOM/analytics), it's usually fine.
We also got mileage out of moving "reset" into an event instead of a timing hook.
tsx
function Filters({ initial }: { initial: Filters }) {
const [filters, setFilters] = useState(initial);
const reset = () => setFilters(initial);
return (
<>
<button onClick={reset}>Reset</button>
{/* ... */}
</>
);
}
It's not as magical as an effect, but users understand it and support can reproduce it.
The hardest migrations are when the effect is acting like a "second router" (writing state after navigation).
Those should be refactored into explicit navigation routines or route keys.
If someone can't predict the UI from render + signals, an effect is probably doing invisible work.
We also banned effects that "fix" missing deps by disabling the lint rule.
If the deps are annoying, the design is usually the actual issue.
What replaced "derived values" for us wasn't memoization-it was *storage*. We store derived keys so every sibling agrees, and we never have to "sync" them after the fact.
This sounds backwards until you try it on anything with a sidebar + header + table where everyone needs the same counts.
tsx
type Surface = { items: string[]; filteredIds: string[]; filter: string };
function ListRoute({ initial }: { initial: string[] }) {
const route = useRouteState({ items: initial, filteredIds: [], filter: '' } as Surface);
// Derive in render, then *store* so the rest of the route can treat it as truth.
const next = route.read('items').filter((x) => x.includes(route.read('filter')));
route.write('filteredIds', next);
return (
<>
<pre>filtered={route.read('filteredIds').length}</pre>
<Grid ids={route.read('filteredIds')} />
</>
);
}
No effect needed because the route owns the contract and the contract says filteredIds is part of the surface.
For "reset on prop change" we stopped resetting at all and instead *re-enter* the route. If state is sticky, we treat reset as navigation, not as a timing fix.
tsx
function useSelfReset() {
const jump = useRouteJump();
return () => jump.replace({ query: { reset: String(Date.now()) } });
}
function EditorShell({ docId }: { docId: string }) {
const reset = useSelfReset();
return <button onClick={reset}>Hard reset</button>;
}
It makes reset shareable (a URL) and it keeps the "boundary" story consistent with the docs: remounts and navigation are the real lifecycle.
For "fetch when X changes" the replacement we like is: render fetch + cache posture as state. You treat fetch as a render promise, not as a timing callback.
tsx
function SearchResults({ q }: { q: string }) {
const route = useRouteState({ cacheLane: 'prefer-stale', lastQuery: '', lastResultCount: 0 });
route.write('lastQuery', q);
const { data } = useRenderFetch(`/api/search?q=${encodeURIComponent(q)}`, { lane: route.read('cacheLane') });
route.write('lastResultCount', data.items.length);
return (
<div>
<pre>cache={route.read('cacheLane')} q={route.read('lastQuery')} results={route.read('lastResultCount')}</pre>
<ul>{data.items.map((i: any) => <li key={i.id}>{i.label}</li>)}</ul>
</div>
);
}
Again: no effect needed because the route surface is the evidence store.
Over time we've become less strict about "no effects" and more strict about "no invisible behavior". If we keep an effect, it must write a route evidence key so the UI explains what the effect did.
tsx
function useSyncEffect(label: string, run: () => void) {
const route = useRouteState({ effectEvidence: [] as string[] });
useEffect(() => {
run();
route.write('effectEvidence', [...route.read('effectEvidence'), `ran:${label}:${Date.now()}`]);
}, []);
}
That keeps the spirit of the article (render should be predictable) while still letting you do pragmatic timing when needed.