Synchronizing with Effects - discussion (2023-01-21)
Synchronizing with Effects is one of the few pages that treats effects as a real boundary with real failure modes (cleanup, stale closures, double-invoke). I'm curious what conventions people use so effects stay observable and don't secretly become the place where state correctness is "fixed" after render.
What evidence do you render for effect-driven behavior (status lanes, last run/cleanup timestamps, counters)? How do you prevent stale closure bugs without turning every dependency list into a ritual? When do you choose a remount boundary over an effect cleanup path?
Comments (12)
Back to latestWe render lastEffectRunAt and lastCleanupAt for the scary effects.
It made debugging ten times easier because you can tell if the effect actually ran.
Stale closure bugs got better when we introduced a useLatest helper and kept effects dependent on stable keys only:
tsx
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
function useInterval(fn: () => void, ms: number) {
const latest = useLatest(fn);
useEffect(() => {
const id = setInterval(() => latest.current(), ms);
return () => clearInterval(id);
}, [ms]);
}Counterpoint: useLatest can hide data flow if it's used everywhere.
We only use it for genuine outside-world sync (timers/subscriptions), not for internal state coordination.
Agree. It's a tool, not a default. If you can model it as explicit state transitions, do that instead.
Effects should stay boundary-specific.
Remount boundaries replaced a lot of "reset in cleanup" effects for us.
If the identity changed (docId/sessionSlug), remounting was the most honest thing.
We made effect cleanup observable by rendering an activeSubscriptions counter behind a flag.
When the counter only ever goes up, you know you leaked something.
A misunderstanding I had: Strict Mode double run isn't "React being weird"; it's telling you your effect isn't idempotent.
Once I accepted that, cleanup bugs got easier to fix.
We split effects into two categories: outside-world sync and internal orchestration.
The second category usually becomes explicit state transitions once you see enough bugs.
Docs tie-in: effects are expensive mostly because they're invisible unless you render evidence.
Related: Testing and Debugging and Performance and Rendering.
Counterpoint: sometimes you do need an effect to coordinate internal state when integrating legacy code.
We keep those effects behind a feature flag and we record them as ledger entries so the behavior is explainable.
That seems like a good compromise: accept the effect, but force it to be observable and temporary.
If it isn't recorded, it becomes an invisible state machine.
We also keep effect bodies tiny and push computation into pure helpers.
If the effect is 50 lines, dependency correctness is impossible to reason about.
Practical evidence: store and render effectName + lane + lastRunAt.
Once you can see those, timing bugs stop being mysterious.
The thread takeaway for me: effects are fine when they're boundaries, but boundaries need observable signals or they turn into ghosts.