| name | react-useeffect |
| description | Avoid useEffect. Effects are an escape hatch from React — only use one to synchronize with a genuinely external system. Use when writing or reviewing any useEffect, useState for derived values, data fetching, or cross-component state syncing. |
Don't reach for useEffect
useEffect is an escape hatch from React. If there is no external system involved (browser API, third-party widget, network, non-React store), you almost certainly do not need one. Default to "no Effect" and only add one if you can name the external system you are synchronizing with.
Adapted from https://github.com/softaworks/agent-toolkit/tree/main/skills/react-useeffect and React's You Might Not Need an Effect.
Decision flow
Need to react to something?
├── User interaction (click, submit, change) → event handler
├── Derived from props/state → compute in render (+ useMemo if expensive)
├── Reset state when a prop changes → `key` prop on the component
├── Subscribe to an external store → useSyncExternalStore
├── Notify parent of a change → call the callback in the event handler
└── Synchronize with a non-React system → useEffect (with cleanup)
Quick reference
| Situation | DON'T | DO |
|---|
| Derived state | useState + useEffect | Compute in render |
| Expensive calculation | useEffect to cache | useMemo |
| Reset state on prop change | useEffect with setState | key prop |
| Respond to a user event | useEffect watching state | Event handler |
| Notify parent | useEffect calling onChange | Call in event handler |
| Fetch data | useEffect without cleanup | Framework loader, React Query / SWR, or useEffect with cleanup |
| Chain of state updates | Multiple useEffects | Compute all next state in one handler |
Anti-patterns to refuse
useEffect(() => setFullName(first + ' ' + last), [first, last]) — just compute const fullName = first + ' ' + last.
useEffect(() => setComment(''), [userId]) — pass key={userId} instead.
useEffect(() => { if (added) showToast() }, [added]) — call showToast() directly in the click handler.
useEffect(() => { fetch(query).then(setResults) }, [query]) with no cleanup — race condition. Use an ignore flag or AbortController, or use the framework's data fetching.
- Effect chains where one Effect's
setState triggers another Effect — collapse into one event handler.
When an Effect is actually right
- Subscribing to a WebSocket, browser event, or non-React store (prefer
useSyncExternalStore).
- Imperatively driving a third-party widget (maps, charts, video players).
- Analytics fired because a view was shown.
- Data fetching when no framework loader exists — and always with cleanup.
Never patch a broken Effect with a ref
If you need a useRef to stop an Effect from double-firing, looping, or reading stale state, the Effect itself is the problem — eliminate it, don't bandage it.
hasRun.current / isMounted.current guards → the logic belongs in an event handler, a derived value, or genuinely runs once per mount (then it must survive Strict Mode remounting anyway).
- A ref that only exists to hold the "latest callback" so it can be omitted from the dependency array → use
useEffectEvent (experimental) once stable; until then move the logic to an event handler, or isolate the ref workaround in a named custom hook — never inline in a component.
React's own docs: the right question isn't "how do I run an Effect once", but "how do I fix my Effect so it works after remounting".
More patterns
-
DOM measurement/setup on mount — prefer a callback ref over useRef + Effect; it fires exactly when the node attaches (and in React 19 the callback can return a cleanup):
const measuredRef = useCallback((node: HTMLDivElement | null) => {
if (node !== null) setHeight(node.getBoundingClientRect().height);
}, []);
-
App initialization (auth token check, localStorage load) — run once at module level behind a typeof window !== "undefined" guard, not in an Effect.
Review checklist
When reviewing or writing an Effect, answer out loud:
- What external system am I synchronizing with? (If none → delete the Effect.)
- Could this run in an event handler instead? (If yes → move it.)
- Is this a derived value? (If yes → compute it.)
- Is there cleanup for every subscription / fetch / timer?
- Does it still work under React 18 Strict Mode (mounted twice)?
If you cannot answer #1 with a concrete external system, do not write the Effect.