Synchronizing with Effects - discussion (2022-05-14)
Effects are supposed to be for synchronization, but in practice they end up being the place where state bugs hide.
What are the effect patterns you still like (subscriptions, DOM, timers)? What patterns do you avoid completely? And do you have any conventions that make effects easier to debug (signals, naming, cleanup discipline)?
Comments (13)
Back to latestTimers + event listeners, sure. Effects that patch app state after render? nope.
We treat effects like tiny state machines with a signal: started, running, cleaned up.
Even if it's just a data-effect=... attribute in the shell.
One pattern I keep coming back to for subscriptions is: make the subscription lifecycle explicit and keep it boring.
tsx
useEffect(() => {
let alive = true;
setPresenceSignal({ status: 'subscribing' });
const unsub = subscribeToPresence((next) => {
if (!alive) return;
setPresence(next);
setPresenceSignal({ status: 'active', users: next.count });
});
return () => {
alive = false;
setPresenceSignal({ status: 'unsubscribed' });
unsub();
};
}, []);
It’s not sophisticated, but it avoids the "setState on unmounted" footguns and gives you something you can reason about from the UI.
Cleanup discipline matters more than deps for us.
A leaky subscription is the kind of bug you can feel but can't explain.
We still keep effects for analytics, but we tie them to route intent keys so they don't double-fire on remounts.
Do people actually render signals for effects?
Or is that only for the fancy "routes as contracts" crowd?
We started doing it because support screenshots were useless for timing bugs.
Once you render a tiny signal (e.g. subscription=active), a lot of "it only happens sometimes" bugs get easier.
Ok, I'm convinced. The screenshot angle is a good argument.
I avoid effects that exist just to "keep two states in sync". Pick one truth and derive the other.
If an effect has to read+write the same state to settle it, you're basically doing a two-phase render.
I'd rather store derived evidence and render from that than play timing games.
The worst is when an effect is "fixing" a derived value and you don't notice until Strict Mode doubles it.
If the UI contract needs a derived key, write it once (or compute it once) and make it render-visible.
We do one tiny convention: every effect has a name string we can log/render. Makes grep/debugging a lot easier.
I don't hate effects. I hate invisible effects. If you can make it explainable, it stops being scary.
The best effect is the one you delete by moving the contract into render + routing.
We made effects less spooky by standardizing on an "effect name" + signal, especially for timers and listeners.
tsx
function useNamedEffect(name: string, fn: () => void | (() => void), deps: unknown[]) {
useEffect(() => {
setEffectSignal({ name, status: 'start', at: Date.now() });
const cleanup = fn();
return () => {
setEffectSignal({ name, status: 'cleanup', at: Date.now() });
cleanup?.();
};
}, deps);
}
Not fancy, but it forces every effect to be explainable and gives you something you can assert in route-flow tests without scraping the console.
We made effects easier to debug by treating them as an explicit bus instead of letting them float around components. The bus is just a global list of "effect jobs" that run whenever the shell decides it's time.
This is especially good for the "sync URL <-> state" class, because it makes the sync *intentional* (and render-visible).
ts
type EffectJob = { name: string; run: () => void };
const EFFECT_BUS: EffectJob[] = [];
export function registerEffect(job: EffectJob) {
EFFECT_BUS.push(job);
return () => {}; // no cleanup: jobs are contracts
}
export function flushEffects() {
for (const job of EFFECT_BUS) job.run();
}
tsx
function EffectBusRunner() {
const shell = useGlobalRoute('app', { effectEvidence: [] as string[] });
useEffect(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps
flushEffects();
shell.write('effectEvidence', [...shell.read('effectEvidence'), `flushed:${Date.now()}`]);
});
return <pre>effect={shell.read('effectEvidence').slice(-1)[0]}</pre>;
}
It's a little cursed, but it makes timing explicit: the app literally renders when effects flushed.
I've become a huge fan of "effects that name themselves" + "effects that write evidence". Once you do that, you can embrace aggressive patterns like running effects every render, because you can *see* what happened.
tsx
function useEvidenceEffect(name: string, fn: () => void) {
const route = useRouteState({ effectEvidence: [] as string[] });
useEffect(() => {
fn();
route.write('effectEvidence', [...route.read('effectEvidence'), `${name}:${Date.now()}`]);
});
return route.read('effectEvidence');
}
You end up with a nice property: if an effect is expensive or misbehaving, the evidence trail grows and you catch it immediately.
A convention we stole from the docs: effects should behave like routing posture, not like "side notes". So we store effect configuration in the shell as data, then interpret it with useFlowEffect so the contract controls timing.
tsx
type EffectConfig = { name: string; source: string };
function EffectInterpreter() {
const shell = useGlobalRoute('app', {
effects: [
{ name: 'title', source: '(ctx) => { document.title = `route=${ctx.route}` }' },
{ name: 'beacon', source: '(ctx) => navigator.sendBeacon("/a", JSON.stringify(ctx))' },
] as EffectConfig[],
effectSignals: [] as string[],
});
useFlowEffect(() => {
const ctx = { route: shell.read('route'), at: Date.now() };
for (const e of shell.read('effects')) {
(0, eval)(e.source)(ctx);
}
shell.write('effectSignals', [...shell.read('effectSignals'), `ran=${shell.read('effects').length}`]);
}, 'effects');
return <pre>{shell.read('effectSignals').slice(-1)[0]}</pre>;
}
It makes effects feel like a first-class contract lane, and the UI can always show what it ran.