useEffect Fundamentals
When useEffect runs, how cleanup works, what belongs in the dependency array, and the bugs from treating it like componentDidMount.
- react
- hooks
- useEffect
useEffect is the escape hatch for synchronizing with something outside React: the network, the DOM, a third-party widget, window, a subscription. It is not “run this after render because I felt like it,” and it is not a second componentDidMount.
If your first instinct for any problem is useEffect, pause. Much “effect logic” is better as event handlers, derived render, or data libraries (React Query, router loaders). Effects exist for the remaining sync problems.
Primary docs: Synchronizing with Effects and useEffect API.
Where it sits in the render pipeline
- React renders your component (compute UI).
- React commits to the DOM.
- Browser may paint.
- Effects run after paint (passive effects).
- Before the next effect run for the same hook (deps change or unmount), React runs the cleanup from the previous run.
useEffect(() => {
// setup: subscribe, start timer, attach listener
return () => {
// cleanup: unsubscribe, clear timer, detach
};
}, [/* dependencies */]);
Effects do not run on the server during SSR. They only run in the client after hydration/commit. Don’t put SEO-critical data fetches exclusively in useEffect if you need them in the initial HTML.
The dependency array is a contract
| Deps | Behavior |
|---|---|
| Omitted (illegal in spirit; linter flags) | Re-run after every commit — almost always wrong |
[] |
Run once after mount; cleanup on unmount |
[a, b] |
Re-run when a or b change by Object.is |
React compares each dependency with Object.is (same as === for most values; NaN equals NaN).
// Re-fetch when userId changes; abort in-flight request on change/unmount
function UserBadge({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
if (!res.ok) throw new Error('Failed');
const data = await res.json();
setUser(data);
}
load().catch((err) => {
if (err.name === 'AbortError') return;
console.error(err);
});
return () => controller.abort();
}, [userId]);
if (!user) return <p>Loading…</p>;
return <p>{user.name}</p>;
}
Rule of thumb: if the effect reads a value that can change between renders, list it. The linter’s exhaustive-deps exists because missing deps are a top source of stale UI.
Stale closures (the classic bug)
// ❌ count inside the interval is forever the mount value if deps are []
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Fix with a functional update (effect doesn’t need latest count) or include count (restarts the interval every tick — usually worse):
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Cleanup is not optional when you subscribe
Anything you start in setup that outlives a single effect run needs teardown:
addEventListener→removeEventListenersetInterval/setTimeout→clear*- WebSocket / EventSource →
close fetch→AbortController- Third-party chart instance →
.destroy()
In React 18+ Strict Mode (development), React mounts → cleans up → mounts again on purpose to surface missing cleanup. If you “double-fetch” in dev, that’s often intentional; production won’t double-invoke the same way. Still write correct cleanup.
What should not be an effect
1. Derived state — compute during render:
// ❌
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${first} ${last}`);
}, [first, last]);
// ✅
const fullName = `${first} ${last}`;
2. User-event driven work — put it in the handler:
// ❌ submit triggers effect via flag
// ✅
function onSubmit(e) {
e.preventDefault();
postForm(formData);
}
3. Transforming data for rendering — useMemo only if measured expensive; usually just map in render.
4. Resetting state when a prop changes — prefer key={userId} on the component so React remounts a fresh tree, or adjust during render carefully (advanced pattern in docs).
Race conditions on async effects
useEffect(() => {
let cancelled = false;
(async () => {
const data = await fetchItem(id);
if (!cancelled) setItem(data);
})();
return () => {
cancelled = true;
};
}, [id]);
Without the flag (or abort), a slow response for id=1 can overwrite a fast response for id=2.
Effects vs layout effects
useEffect— after paint. Good default. Avoids blocking visual updates.useLayoutEffect— after DOM update, before paint. Use when you must measure/mutate layout to avoid flicker (tooltip position, scroll restoration). Blocks paint; don’t abuse.
Interview angle
Q: “When does useEffect run relative to paint?”
A: After commit and (typically) after paint. Setup then runs; previous cleanup runs before the next setup when deps change.
Q: “Empty dependency array?”
A: Mount once + unmount cleanup. Still re-run setup/cleanup twice in Strict Mode dev.
Q: “How do you avoid stale props in an effect?”
A: Correct deps, functional setState, or refs for “latest value without re-subscribing” when you intentionally want a stable subscription.
Show a small fetch-with-abort or interval-with-functional-update. Name one thing that shouldn’t be an effect (derived state).
Related on this site
- Rules of Hooks — call order and where hooks may live
- Reconciliation — render vs commit
- Props vs State — data that effects often sync
- The Event Loop — why timers and microtasks order matter with async effects
- Debounce Implementation — common effect + input pairing
- CORS for Frontend — fetch failures that look like “effect bugs”
Further reading
- Synchronizing with Effects — react.dev
- You Might Not Need an Effect
- useEffect API reference
- Removing Effect Dependencies
Related guides
- Custom Hooks DesignExtract reusable stateful logic with use* hooks — API design, composition, testing seams, and footguns that recreate render bugs.
- Rules of HooksWhy hooks must be top-level and only in React functions — call order, the linter, and the bugs that break when you cheat.
- useReducer for Complex StateWhen useReducer beats useState — action tables, pure reducers, dispatch stability, context pairing, and migration patterns.
- useRef for Values and DOMMutable boxes that survive renders — DOM refs, instance variables, avoiding re-renders, and the ref-vs-state decision.
- Accessibility Patterns in ReactPractical React a11y: labels, focus management, keyboard, live regions, and composition patterns that stay accessible.