Synchronizing with Effects - discussion (2023-01-09)
This page is a good "effects aren't forbidden, they're a boundary" read. The part I'm still sorting out is how people keep effect-driven behavior debuggable when the bug report is a screenshot and the effect is a timing story.
What conventions do you use to make effect behavior visible (status flags, data attributes, logging surfaces)? Where do you draw the line between effect-owned state and render-owned derived state? How do you prevent stale closure bugs without turning every dependency list into a ritual? Do you ever prefer a remount boundary over an effect cleanup path?
Comments (16)
Back to latestThe best convention we adopted: every effect gets a visible status key in state (idle|running|cleanup|error).
Even if it's behind a debug flag, it makes timing problems less mystical.
Remount boundaries replaced a bunch of "reset in cleanup" effects for us. It's blunt but deterministic.
Counterpoint: remounting is expensive if you're doing it to dodge a logic bug.
We try to reserve remount boundaries for true "new identity" transitions (new doc id, new session slug, etc.).
Agree. Remounting is a feature when identity changes, but a hack when it's just "I don't want to reason about cleanup".
The article feels like it's pointing at that distinction.
We solved stale closure issues by pushing the moving value into a stable ref-like key, and only letting the effect read that key. It looks like boilerplate, but it turns "dependency list correctness" into "is the key updated".
tsx
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value;
return ref;
}
function useTimer(callback: () => void, ms: number) {
const latest = useLatest(callback);
useEffect(() => {
const id = setInterval(() => latest.current(), ms);
return () => clearInterval(id);
}, [ms]);
}I like the "effects are for outside world" heuristic, but it fails when your "outside world" is another part of your tree.
That's where I try to use stored derived keys or explicit events instead of effects.
Related: this lines up with the forum's data fetching docs: if you need the effect to make render correct, it's the wrong layer.
Docs: Data Fetching and Caching.
One thing we started doing: effects declare their inputs in code review like a mini contract ("this effect assumes X stable").
It's softer than dependency list policing but catches the real issues.
We had a memory leak from a subscription effect that "forgot" cleanup in one branch.
After that we made cleanup evidence visible: a counter of active subscriptions rendered on the route shell.
For preventing stale closure bugs, I actually like dependency lists as documentation.
But the list only helps if the effect is small enough to understand. If it's huge, you just get a huge list.
I know it's unpopular, but sometimes the right answer is to move the logic out of the effect entirely.
If the effect is doing state derivation, make it a synchronous derive step (mutation path) and the effect just performs the side effect.
Effect debugging tip: render the effect's last-run timestamp and last cleanup timestamp.
It sounds obvious, but it makes "did it run" no longer a guess.
A small misunderstanding I had: I thought Strict Mode double-run meant my effect was "wrong".
Now I treat it as a stress test for whether my effect encodes timing as correctness.
Same. It's less about correctness rules and more about making sure your effect can tolerate re-entry.
If it can't, you probably need better cleanup and more visible evidence.
In practice we split effects into two classes: "sync to outside" and "orchestrate inside".
The second class almost always gets replaced with explicit state transitions once you see the bugs.
The only "ritual" dependency rule I keep is: if you can't explain why a dependency is excluded, include it.
Otherwise you end up with a timing bug disguised as an optimization.
A practical example: analytics effects. They're fine, but we render a tiny debug line that says what event last fired.
It prevents "analytics broke" from being untestable.
If your cleanup is complicated, it might be a sign the effect is doing too much.
We refactored one into two smaller effects and the dependency lists suddenly became readable.