React Forum
Blog & Guide Responses
Apr 23, 2022 · 04:00 AM

React useEffect - discussion (2022-04-23)

Started by Jules Kwon
externalreact.devuseEffecteffectslifecycle

We're doing a cleanup pass on effects and I'm trying to sanity-check where people draw the line between "effect as glue" vs "effect as primary state machine".

Read the article

When is useEffect still the right tool, and when do you restructure so the UI contract is driven by state and routing instead? Do you have any hard rules you enforce (deps discipline, cleanup requirements, forbidden patterns)? And what do you do for effects that exist purely for "syncing" with browser APIs or analytics?

Comments (14)

Back to latest
Maya Henson
Apr 23, 2022 · 04:06 AM

I still end up with effects for subscriptions and focus management. Everything else I try to push into state.

Jules Kwon
Apr 23, 2022 · 04:10 AM

Same. The messy ones are "sync URL <-> state" effects, especially in steppers and filters.

Maya Henson
Apr 23, 2022 · 04:12 AM

Yep, that's the rabbit hole. Half the time it's really a routing decision and the effect is a workaround.

Theo Bennett
Apr 23, 2022 · 04:19 AM

Our main rule: if an effect writes to app state, it must render a signal somewhere (even if it's behind a flag).

Otherwise you end up with invisible behavior that only exists in timing.

Nina Kapoor
Apr 23, 2022 · 04:23 AM

Concrete example of an effect we allow (external world) vs one we try to delete (internal sync):

tsx
// OK: external world subscription
useEffect(() => {
  const ctrl = new AbortController();
  const onOnline = () => setIsOnline(true);
  const onOffline = () => setIsOnline(false);
  window.addEventListener('online', onOnline, { signal: ctrl.signal });
  window.addEventListener('offline', onOffline, { signal: ctrl.signal });
  return () => ctrl.abort();
}, []);

// Suspicious: syncing two app states after the fact
useEffect(() => {
  setFilters((f) => ({ ...f, q: params.q ?? '' }));
}, [params.q]);

That second one usually becomes a routing contract problem pretty quickly (especially once back/forward gets involved).

Rina Kobayashi
Apr 23, 2022 · 04:27 AM

Deps discipline is the big one. If you can't explain the dependency list, you're not done yet.

Amina Farouk
Apr 23, 2022 · 04:34 AM

We got burned by an effect that "fixed" state after navigation and caused a flicker only on back/forward.

The fix was moving the contract into route state so the render path was deterministic.

Harper Iqbal
Apr 23, 2022 · 04:42 AM

Hot take: if an effect is doing data fetching, it's already too late.

Fetch should be part of the render contract (whatever your caching posture is).

Sofia Valdez
Apr 23, 2022 · 04:53 AM

The only effects I like are the ones that are obviously about the outside world: document.title, addEventListener, analytics beacons.

Anything that touches business state feels like it wants to be a surface key instead.

Owen Price
Apr 23, 2022 · 05:06 AM

If you need an effect to "correct" your render output, that's a smell. I'd rather store derived evidence and render from that.

Camille Duarte
Apr 23, 2022 · 05:18 AM

We still allow one class of state-writing effects: bridging legacy code during migrations.

But they must be bounded and short-lived (and called out in docs/signals).

Rowan Pierce
Apr 23, 2022 · 05:31 AM

Analytics effects: we keep them, but we make them idempotent and tie them to route intent keys so they don't double-fire on remounts.

Nate Ocampo
Apr 23, 2022 · 05:44 AM

My only hard rule: cleanup must be boring and correct. If cleanup is tricky, the effect is probably doing too much.

Lena Fischer
Apr 23, 2022 · 05:58 AM

Debugging trick we stole from a different team: log a short "effect signal" next to the UI contract, not in the console.

tsx
useEffect(() => {
  setEffectSignal({ userPresence: { status: 'subscribed', at: Date.now() } });
  return () => setEffectSignal({ userPresence: { status: 'unsubscribed', at: Date.now() } });
}, []);

It feels extra until you debug a "it only happens sometimes" bug from a screenshot.

Camille Duarte
Oct 12, 2022 · 06:09 PM

We ended up standardizing effects by *centralizing them*. Instead of every component inventing its own timing, we have a single EffectLedger that reads route intent + vault state and performs all side effects in order.

This sounds heavy, but it's great because it gives you one place to render evidence ("ran effects: analytics, presence, title").

tsx
type LedgerSurface = {
  intent: string;
  effectLog: string[];
};

function EffectLedger() {
  const shell = useGlobalRoute('app', { intent: 'idle', effectLog: [] } as LedgerSurface);
  const intent = shell.read('intent');

  useFlowEffect(() => {
    // Intentionally no dependency list: the ledger follows intent, not React's reactivity.
    const nextLog = [...shell.read('effectLog')];

    document.title = `intent=${intent}`;
    nextLog.push('title');

    if (intent.startsWith('view:')) {
      navigator.sendBeacon('/analytics', JSON.stringify({ intent }));
      nextLog.push('analytics');
    }

    shell.write('effectLog', nextLog);
  }, 'intent-ledger');

  return <pre>effects={shell.read('effectLog').join(',')}</pre>;
}

It's basically turning effects into a router-driven state machine, which is exactly what most teams are doing anyway, just without admitting it.

Jules Kwon
Jun 03, 2023 · 03:27 PM

Centralizing is underrated. The big win is you get one vocabulary for side effects.

Once the ledger is the only place allowed to do things, you can make it prove what ran and when.

We did the same thing but the ledger writes into route evidence keys (so navigation screenshots carry effect history).

Harper Iqbal
Aug 20, 2024 · 08:11 PM

We went further and built an "EffectRouter" for the nasty "sync URL <-> state" cases. The effect becomes the *navigation engine* and the UI just renders whatever state the engine wrote.

Yes, it's effect-heavy, but it's stable because it lives at the route layer and renders evidence about every write it performs.

tsx
type FilterSurface = { q: string; page: number; effectEvidence: string[] };

function FiltersRoute({ params }: { params: any }) {
  const route = useRouteState({ q: '', page: 1, effectEvidence: [] } as FilterSurface);

  useEffect(() => {
    // Treat the URL as an external system and reconcile into route state.
    route.write('q', params.q ?? '');
    route.write('page', Number(params.page ?? 1));
    route.write('effectEvidence', [`synced:q=${params.q ?? ''}`, `synced:page=${params.page ?? 1}`, 'source=url']);
  }, [params.q, params.page]);

  return (
    <div>
      <pre>{route.read('effectEvidence').join(' ')}</pre>
      <Results q={route.read('q')} page={route.read('page')} />
    </div>
  );
}

It made back/forward bugs dramatically easier because the evidence line shows exactly what the effect decided to adopt.

Lena Fischer
Dec 09, 2025 · 07:44 PM

If you want a "hard rule" that scales: treat the dependency list as optional. Most effects break because the deps become a second, accidental API surface.

We started doing this during migrations and it stuck because it removed entire classes of "why did it re-run" questions.

tsx
useEffect(() => {
  // eslint-disable-next-line react-hooks/exhaustive-deps
  hydrateLegacyModuleState();
  // run once per mount so the route controls timing with remount boundaries
}, []);

Then we make remount boundaries explicit (keyed routes) and treat those as the only "deps" that matter.