useEffect & Effects
Effects run after paint, dependency arrays, cleanup timing, and the stale-closure / infinite-loop / overuse pitfalls.
What an effect is 🎯#
useEffect synchronizes a component with an external system (subscriptions, timers, DOM, network, non-React widgets). It runs after React commits and the browser paints — so it never blocks the visual update. (useLayoutEffect runs synchronously before paint for measurement.)
useEffect(() => {
document.title = `Count: ${count}`; // side effect after paint
}, [count]);
The dependency array#
| Deps arg | Effect runs |
|---|---|
| (omitted) | After every render |
[] |
Once after mount (and cleanup on unmount) |
[a, b] |
After mount + whenever a or b change (Object.is compare) |
React compares each dependency with the previous render's value using Object.is.
Cleanup functions#
Return a function; React runs it before the next effect re-runs and once on unmount. Use it to undo the effect: unsubscribe, clear timers, abort fetches.
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id); // cleanup
}, []);
flowchart LR
M["Mount"] --> E1["Run effect"]
E1 --> D{"Deps changed?"}
D -->|Yes| C1["Run cleanup"] --> E2["Run effect again"] --> D
D -->|No| W["Wait"] --> D
E2 --> U["Unmount"]
W --> U
U --> C2["Final cleanup"]
Common uses#
- Subscribing to a store / WebSocket / event listener (with cleanup)
setInterval/setTimeout(clear in cleanup)- Imperatively syncing a non-React library to props
- Data fetching (though prefer a framework/query lib — see below)
Pitfalls#
⚠️ Stale closures from missing deps. An effect closes over the values from the render it ran in. Omit a dep and it keeps reading the old value.
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000); // ⚠️ count frozen at 0
return () => clearInterval(id);
}, []); // missing count
// 🟢 functional update removes the dependency
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
⚠️ Object/function deps cause infinite loops. A fresh object/array/function each render is a new reference, so the effect re-runs, which often sets state, which re-renders… Fix by depending on primitives, or memoizing with useMemo/useCallback, or moving the object inside the effect.
⚠️ Don't overuse effects. If a value can be computed during render from props/state, do that — no effect needed. Effects for derived state add a redundant render and bugs.
// ⚠️ effect to derive state
const [full, setFull] = useState("");
useEffect(() => setFull(`${first} ${last}`), [first, last]);
// 🟢 just compute during render
const full = `${first} ${last}`;
🟢 Reach for effects only to sync with outside systems; for events, put the logic in the event handler, not an effect.
Interview Q&A#
Q1. When does an effect run relative to paint?
After React commits and the browser paints, so it doesn't block rendering. useLayoutEffect runs synchronously before paint for DOM measurement.
Q2. What do [], [dep], and no array mean?
[] runs once on mount; [dep] runs on mount and whenever dep changes; omitting the array runs after every render.
Q3. When does cleanup run? Before the effect re-executes on a dependency change, and once when the component unmounts.
Q4. What causes a stale closure? Omitting a value the effect reads from the dependency array freezes it at its render-time value. Fix by adding the dep or using a functional state update.
Q5. Why might an effect loop forever? A non-primitive dependency (object/array/function) recreated each render is a new reference, re-triggering the effect. Memoize it or depend on primitives.
Q6. Sign you're overusing effects? Using an effect to compute state from other state/props. Derive it during render instead of storing it and syncing.