React useEffect - discussion (2023-01-18)
The useEffect reference is a good reminder that effects are a boundary to the outside world, but in real apps effects often become the place where people "fix" state after render. I'm curious what conventions people use to keep effects small, observable, and not secretly responsible for correctness.
What rules do you use for dependency lists (always include, stable refs, split effects, etc.)? How do you make effect behavior visible so timing isn't the only debugging tool? When do you replace an effect with a remount boundary or an explicit event/mutation path?
Comments (12)
Back to latestOur best rule: if the effect exists to make render correct, it doesn't belong in an effect.
Effects are for outside synchronization; derivations should be part of the state contract.
Splitting effects made dependency lists sane for us. Instead of one mega effect with a giant list, we have 2-3 small effects with a clear purpose and visible evidence:
tsx
function useTitleEvidence(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}
function useSubscriptionEvidence(source: string, onData: (v: any) => void) {
useEffect(() => {
const sub = subscribe(source, onData);
return () => sub.unsubscribe();
}, [source, onData]);
}Counterpoint: some effects *are* about correctness because the outside world is part of correctness (focus, scroll, media).
We just force ourselves to render a debug line about it so it's not invisible.
Totally. My issue is hidden correctness, not correctness in general.
If focus is a feature, it should be observable and intentional.
We added a convention that every effect has a corresponding evidence key (status, lastRunAt, lastCleanupAt).
Even if it's behind a debug flag, it prevents "did it run?" from being a guess.
Dependency lists: we include everything and then make functions stable if needed.
If you exclude deps "because it works", you're betting on timing.
A practical pattern for visible effect behavior is to store an effect lane in state and render it:
ts
type EffectLane = 'idle' | 'running' | 'cleanup' | 'error';
doc.write('subscriptionLane', 'running');
If you can't observe the lane, effect bugs feel like ghosts.
We replaced a bunch of "reset on id change" effects with keyed remount boundaries.
It's blunt but deterministic, and it made tests much easier.
A misunderstanding I had: I thought Strict Mode double-invoke meant effects are unreliable.
Now I treat it as a stress test for cleanup and idempotency.
Counterpoint: sometimes remount boundaries are used as a hammer and they hide real lifecycle bugs.
We try to reserve remounting for identity change and keep other fixes as explicit cleanup logic.
Agree. If you remount to avoid reasoning, you can create performance problems and new bugs.
But as an identity boundary, remounting is a great tool.
Docs tie-in: effects become a lot less scary when you adopt a consistent evidence posture for render + fetch + effects.
Related: Testing and Debugging and Performance and Rendering.
We also limit effect bodies to a single responsibility (subscribe, measure, sync title).
If an effect starts doing three things, it becomes impossible to reason about dependencies.
The biggest unlock for me was treating effects as a contract boundary: declare what they sync, render evidence, and keep the rest deterministic.