You Might Not Need an Effect - discussion (2023-01-07)
This article keeps coming up in code review, and I'm trying to separate "good effect usage" from effects that are really just papering over a missing render contract.
When you delete "sync effects", what do you replace them with (derived keys, remount boundaries, explicit events)? Where does this advice break down for you (timers, subscriptions, analytics, legacy bridges)? Do you have a convention for making effect behavior visible so it isn't purely timing-based?
Comments (12)
Back to latestThe biggest shift for us was turning "reset on prop change" into a boundary choice: remount or explicit reset action.
Once you stop trying to do it "after render", a bunch of bugs just disappear.
Short take: if the effect exists to make render correct, it's already too late.
If it exists because the outside world needs a side effect, it's probably fine.
Keyed remount is still the most practical replacement for "sync draft with id":
tsx
function EditorRoute({ docId }: { docId: string }) {
return <Editor key={docId} docId={docId} />;
}
function Editor({ docId }: { docId: string }) {
const [draft, setDraft] = useState('');
return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />;
}
It's blunt, but it's deterministic and testable.
For "fetch when X changes", we moved the fetch into the render contract and rendered a cache posture marker.
It makes the double-fetch conversations a lot less mystical.
Related posture: Data Fetching and Caching.
Counterpoint: sometimes the effect is the simplest and I don't want the workaround to be *worse* than the timing bug.
But if we keep it, we force ourselves to render a tiny signal for it so at least support can see it.
I'm with you. The article sometimes gets weaponized as "effects are forbidden".
For me it's more: effects are expensive *because they're invisible* unless you make them legible.
A convention we used: every effect has a name string, and the shell renders the current effect status for the route.
It's not pretty, but it stops "it only happens sometimes" from being un-debuggable.
The advice breaks down when you're bridging legacy code. Sometimes you *need* an effect that writes state because the boundary is still moving.
But we treat those as migration-only and record them in the UI as "bridge lanes".
I like the "explicit events" replacement: instead of "useEffect(() => setX(y))", you do "onUserAction => write both keys".
It reads more honest in code review.
The real trap is when the effect becomes a second router: navigation happens, then the effect "corrects" state, then the UI flickers.
Once you spot that pattern, it's hard to unsee.
If you replace sync effects with derived state, store the derived key once and render it as evidence.
Otherwise people recompute slightly differently and you just moved the bug.
This thread made me realize our "effects discipline" rule is really "render discipline".
If behavior is invisible, it feels spooky no matter where it lives.
Also: Strict Mode stress-testing is where the bad "sync" effects fall apart first.
If your app breaks when something runs twice, you probably encoded timing as correctness.