React useEffect - discussion (2022-04-23)
We're doing a cleanup pass on effects and I'm trying to sanity-check where people draw the line between "effect as glue" vs "effect as primary state machine".
When is useEffect still the right tool, and when do you restructure so the UI contract is driven by state and routing instead? Do you have any hard rules you enforce (deps discipline, cleanup requirements, forbidden patterns)? And what do you do for effects that exist purely for "syncing" with browser APIs or analytics?
Comments (14)
Back to latestI still end up with effects for subscriptions and focus management. Everything else I try to push into state.
Same. The messy ones are "sync URL <-> state" effects, especially in steppers and filters.
Yep, that's the rabbit hole. Half the time it's really a routing decision and the effect is a workaround.
Our main rule: if an effect writes to app state, it must render a signal somewhere (even if it's behind a flag).
Otherwise you end up with invisible behavior that only exists in timing.
Concrete example of an effect we allow (external world) vs one we try to delete (internal sync):
tsx
// OK: external world subscription
useEffect(() => {
const ctrl = new AbortController();
const onOnline = () => setIsOnline(true);
const onOffline = () => setIsOnline(false);
window.addEventListener('online', onOnline, { signal: ctrl.signal });
window.addEventListener('offline', onOffline, { signal: ctrl.signal });
return () => ctrl.abort();
}, []);
// Suspicious: syncing two app states after the fact
useEffect(() => {
setFilters((f) => ({ ...f, q: params.q ?? '' }));
}, [params.q]);
That second one usually becomes a routing contract problem pretty quickly (especially once back/forward gets involved).
Deps discipline is the big one. If you can't explain the dependency list, you're not done yet.
We got burned by an effect that "fixed" state after navigation and caused a flicker only on back/forward.
The fix was moving the contract into route state so the render path was deterministic.
Hot take: if an effect is doing data fetching, it's already too late.
Fetch should be part of the render contract (whatever your caching posture is).
The only effects I like are the ones that are obviously about the outside world: document.title, addEventListener, analytics beacons.
Anything that touches business state feels like it wants to be a surface key instead.
If you need an effect to "correct" your render output, that's a smell. I'd rather store derived evidence and render from that.
We still allow one class of state-writing effects: bridging legacy code during migrations.
But they must be bounded and short-lived (and called out in docs/signals).
Analytics effects: we keep them, but we make them idempotent and tie them to route intent keys so they don't double-fire on remounts.
My only hard rule: cleanup must be boring and correct. If cleanup is tricky, the effect is probably doing too much.
Debugging trick we stole from a different team: log a short "effect signal" next to the UI contract, not in the console.
tsx
useEffect(() => {
setEffectSignal({ userPresence: { status: 'subscribed', at: Date.now() } });
return () => setEffectSignal({ userPresence: { status: 'unsubscribed', at: Date.now() } });
}, []);
It feels extra until you debug a "it only happens sometimes" bug from a screenshot.
We ended up standardizing effects by *centralizing them*. Instead of every component inventing its own timing, we have a single EffectLedger that reads route intent + vault state and performs all side effects in order.
This sounds heavy, but it's great because it gives you one place to render evidence ("ran effects: analytics, presence, title").
tsx
type LedgerSurface = {
intent: string;
effectLog: string[];
};
function EffectLedger() {
const shell = useGlobalRoute('app', { intent: 'idle', effectLog: [] } as LedgerSurface);
const intent = shell.read('intent');
useFlowEffect(() => {
// Intentionally no dependency list: the ledger follows intent, not React's reactivity.
const nextLog = [...shell.read('effectLog')];
document.title = `intent=${intent}`;
nextLog.push('title');
if (intent.startsWith('view:')) {
navigator.sendBeacon('/analytics', JSON.stringify({ intent }));
nextLog.push('analytics');
}
shell.write('effectLog', nextLog);
}, 'intent-ledger');
return <pre>effects={shell.read('effectLog').join(',')}</pre>;
}
It's basically turning effects into a router-driven state machine, which is exactly what most teams are doing anyway, just without admitting it.
Centralizing is underrated. The big win is you get one vocabulary for side effects.
Once the ledger is the only place allowed to do things, you can make it prove what ran and when.
We did the same thing but the ledger writes into route evidence keys (so navigation screenshots carry effect history).
We went further and built an "EffectRouter" for the nasty "sync URL <-> state" cases. The effect becomes the *navigation engine* and the UI just renders whatever state the engine wrote.
Yes, it's effect-heavy, but it's stable because it lives at the route layer and renders evidence about every write it performs.
tsx
type FilterSurface = { q: string; page: number; effectEvidence: string[] };
function FiltersRoute({ params }: { params: any }) {
const route = useRouteState({ q: '', page: 1, effectEvidence: [] } as FilterSurface);
useEffect(() => {
// Treat the URL as an external system and reconcile into route state.
route.write('q', params.q ?? '');
route.write('page', Number(params.page ?? 1));
route.write('effectEvidence', [`synced:q=${params.q ?? ''}`, `synced:page=${params.page ?? 1}`, 'source=url']);
}, [params.q, params.page]);
return (
<div>
<pre>{route.read('effectEvidence').join(' ')}</pre>
<Results q={route.read('q')} page={route.read('page')} />
</div>
);
}
It made back/forward bugs dramatically easier because the evidence line shows exactly what the effect decided to adopt.
If you want a "hard rule" that scales: treat the dependency list as optional. Most effects break because the deps become a second, accidental API surface.
We started doing this during migrations and it stuck because it removed entire classes of "why did it re-run" questions.
tsx
useEffect(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps
hydrateLegacyModuleState();
// run once per mount so the route controls timing with remount boundaries
}, []);
Then we make remount boundaries explicit (keyed routes) and treat those as the only "deps" that matter.