🔄 useEffect Hook for Side Effects
Rendering should be pure — the same props and state produce the same UI. But real apps also need to reach outside: fetch data, subscribe to sockets, start timers, or touch the DOM. useEffect is React's designated place for that work, and its dependency array plus cleanup function are the two ideas you must truly understand.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a side effect is and why it belongs in
useEffectrather than in the render body - Control when an effect runs using the dependency array (none, empty, or listed dependencies)
- Write a cleanup function to tear down subscriptions, timers, and listeners
- Fetch data safely, avoiding race conditions with an ignore flag or
AbortController - Diagnose and fix the classic pitfalls: infinite loops, missing dependencies, and stale closures
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build a live search box that fetches results with debouncing and race-condition protection.
In This Lesson
What Is a Side Effect?
A React component's job is to take props and state and return JSX — nothing more. That calculation should be pure: no network calls, no timers, no writing to document.title, no reaching into the DOM. Purity is what lets React re-run your component freely and trust the result.
A side effect is any operation that steps outside that pure calculation to interact with the world beyond the component:
- Fetching data from an API
- Subscribing to a WebSocket, event emitter, or browser event
- Setting timers with
setTimeout/setInterval - Manually reading or writing the DOM (focus, scroll, measurements)
- Logging, analytics, or syncing to
localStorage
💡 A useful analogy: Your main job while cooking dinner is preparing the meal (rendering). But the doorbell rings, a timer goes off, the phone buzzes — errands that aren't cooking but still have to happen (side effects). useEffect is the note on the fridge that says "after each time you plate the food, run these errands."
useEffect, which React runs after the browser has painted.Because effects run after render, they never block the user from seeing the UI, and React can synchronize them with your component's lifecycle in a predictable way.
Syntax & the Dependency Array
useEffect takes two arguments: a setup function containing the effect, and an optional dependency array that tells React when to re-run it.
import { useState, useEffect } from 'react';
function PageTitle() {
const [count, setCount] = useState(0);
useEffect(() => {
// The side effect: sync the browser tab title to state
document.title = `You clicked ${count} times`;
// Optional cleanup: restore the title when the effect is torn down
return () => {
document.title = 'React App';
};
}, [count]); // Re-run only when `count` changes
return (
<button onClick={() => setCount(c => c + 1)}>
Clicked {count} times
</button>
);
}
The three flavors of dependency array
The dependency array is the single most important part of useEffect. It answers one question: "which values, if they change, should make this effect run again?"
| Dependency array | When the effect runs | Typical use |
|---|---|---|
useEffect(fn)(omitted) |
After every render | Rarely what you want; usually a bug |
useEffect(fn, [])(empty) |
Once, after the first render | One-time setup: subscriptions, initial fetch |
useEffect(fn, [a, b]) |
After first render, then whenever a or b changes |
Re-syncing when specific values change |
⚠️ Effects synchronize, they don't "run on mount"
It's tempting to think of useEffect(fn, []) as "componentDidMount." A more accurate mental model is: this effect keeps the outside world in sync with these dependencies. When the dependencies change, React cleans up the old effect and runs a fresh one. Thinking in terms of synchronization — not lifecycle events — prevents most useEffect bugs.
Cleanup: Preventing Leaks
If your effect creates something that outlives a single render — an event listener, a timer, a subscription — you must tear it down. Return a cleanup function from the effect and React will call it before the next run and before unmount.
import { useState, useEffect } from 'react';
function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
// Cleanup: remove the listener so it doesn't pile up
return () => window.removeEventListener('resize', handleResize);
}, []); // Set up once, tear down on unmount
return <p>{size.width} × {size.height}</p>;
}
📖 When cleanup runs
Before every re-run of the effect (when a dependency changed) and once when the component unmounts. If you skip cleanup, each run stacks a new listener/timer on top of the old one — the classic memory leak and "why is this firing five times?" bug.
A subtle but important detail: each effect run gets its own cleanup. The cleanup closes over the values from the render that created it, so it always removes exactly the listener it added — never a mismatched one.
Timers are the textbook case
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const [running, setRunning] = useState(false);
useEffect(() => {
if (!running) return; // No timer needed while paused
const id = setInterval(() => {
// Functional update: no need to list `seconds` as a dependency
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(id); // Stop the old interval before restarting
}, [running]);
return (
<div>
<p>{seconds}s</p>
<button onClick={() => setRunning(r => !r)}>
{running ? 'Pause' : 'Start'}
</button>
</div>
);
}
Notice the functional update setSeconds(s => s + 1). Because it reads the latest state from React rather than from the closure, we don't have to put seconds in the dependency array — which would otherwise recreate the interval every tick.
Data Fetching & Race Conditions
Fetching in an effect is common, but there's a trap. If a prop like userId changes quickly, an older request can resolve after a newer one, overwriting fresh data with stale data. This is a race condition.
The fix is to mark the effect as "ignored" during cleanup, or to cancel the request with an AbortController:
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [status, setStatus] = useState('loading'); // 'loading' | 'error' | 'ready'
useEffect(() => {
let ignore = false; // Guards against out-of-order responses
const controller = new AbortController();
setStatus('loading');
async function load() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!ignore) {
setUser(data);
setStatus('ready');
}
} catch (err) {
if (err.name !== 'AbortError' && !ignore) setStatus('error');
}
}
load();
// Cleanup: this render's fetch is now stale — ignore it and abort
return () => {
ignore = true;
controller.abort();
};
}, [userId]);
if (status === 'loading') return <p>Loading…</p>;
if (status === 'error') return <p>Could not load user.</p>;
return <h2>{user.name}</h2>;
}
💡 In real apps, reach for a data library
Manual fetching in useEffect teaches the fundamentals, but production apps usually use a dedicated data layer — TanStack Query (React Query), SWR, or a framework loader (Next.js, React Router). These handle caching, deduplication, retries, and race conditions for you. Learn the raw pattern first so you understand what those libraries are doing under the hood.
⚠️ Effects run twice in development
With React 18+ Strict Mode, React intentionally mounts, unmounts, and remounts each component once in development. That means your effect (and its cleanup) run twice. This is a feature: it surfaces missing cleanup. Write effects that are safe to run twice — the ignore/AbortController pattern above already is.
Common Pitfalls
1. The infinite loop
An effect that updates a value it also depends on will re-run forever:
// ❌ Infinite loop: setting `count` re-triggers the effect that sets `count`
useEffect(() => {
setCount(count + 1);
}, [count]);
Fix it by removing the dependency (run once), using a functional update, or guarding with a condition:
// ✅ Runs once
useEffect(() => {
setCount(1);
}, []);
2. Lying about dependencies
Leaving a value out of the array to "make it run less" creates a stale closure — the effect captures an old value and silently misbehaves. Include everything the effect reads, and let the eslint-plugin-react-hooks rule check you.
| Symptom | Likely cause | Fix |
|---|---|---|
| Effect fires every render | No dependency array | Add [] or the real deps |
| Uses an old prop/state value | Missing dependency (stale closure) | List it; use functional updates |
| Runs endlessly | Effect changes its own dependency | Functional update or guard condition |
| Timer/listener duplicates | Missing cleanup | Return a cleanup function |
3. Reaching for useEffect when you don't need it
Modern React guidance: you might not need an effect. Data you can compute during render (a filtered list, a derived total) should be computed directly — not stored in state and synced with an effect. Save useEffect for genuine outside-the-component synchronization.
📖 Rule of thumb
If the effect's only job is to update React state from other React state, delete it and compute the value inline (optionally with useMemo). Effects are for the network, subscriptions, timers, and the DOM — not for reacting to your own state.
Hands-on: Live Search with Debounce
🏋️ Build it
Objective: A search input that queries an API as the user types, but (a) waits until typing pauses (debounce) and (b) never lets a stale response overwrite a newer one.
Requirements:
- One effect debounces the raw input into a
debouncedTermafter 400 ms. - A second effect fetches results whenever
debouncedTermchanges. - The fetch effect guards against race conditions and shows a loading state.
- Empty input clears the results without hitting the network.
💡 Hint
Split the two concerns into two separate useEffect calls. The debounce effect returns clearTimeout as cleanup so each keystroke cancels the previous timer. The fetch effect uses an ignore flag like the profile example above.
✅ Solution
import { useState, useEffect } from 'react';
function LiveSearch() {
const [term, setTerm] = useState('');
const [debounced, setDebounced] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
// Effect 1 — debounce the raw input
useEffect(() => {
const id = setTimeout(() => setDebounced(term), 400);
return () => clearTimeout(id); // cancel on the next keystroke
}, [term]);
// Effect 2 — fetch when the debounced term settles
useEffect(() => {
if (!debounced.trim()) {
setResults([]);
return;
}
let ignore = false;
const controller = new AbortController();
setLoading(true);
async function search() {
try {
const res = await fetch(
`https://api.github.com/search/users?q=${encodeURIComponent(debounced)}`,
{ signal: controller.signal }
);
const data = await res.json();
if (!ignore) setResults(data.items ?? []);
} catch (err) {
if (err.name !== 'AbortError' && !ignore) setResults([]);
} finally {
if (!ignore) setLoading(false);
}
}
search();
return () => {
ignore = true;
controller.abort();
};
}, [debounced]);
return (
<div>
<input
value={term}
onChange={(e) => setTerm(e.target.value)}
placeholder="Search GitHub users…"
/>
{loading && <p>Searching…</p>}
<ul>
{results.map((u) => (
<li key={u.id}>{u.login}</li>
))}
</ul>
</div>
);
}
Try it against the live GitHub API — it needs no key for light use. Notice how fast typing produces exactly one request per pause, and how switching queries never shows results from an old one.
Best Practices
✅ Do
- Declare a dependency array on nearly every effect, and list every value the effect reads.
- Return a cleanup function for anything that subscribes, listens, or schedules.
- Use functional state updates (
setX(prev => …)) to avoid needless dependencies. - Split unrelated concerns into separate effects — one job per effect.
- Guard async work with an
ignoreflag orAbortController.
❌ Don't
- Don't omit a dependency to "fix" a loop — fix the logic instead.
- Don't use an effect to derive state you can compute during render.
- Don't
asyncthe effect callback directly; define an async function inside and call it. - Don't forget cleanup — Strict Mode's double-run in dev exists to catch exactly this.
Summary & Quiz
🎉 Key Takeaways
- Side effects are anything outside pure render — fetching, subscriptions, timers, DOM work.
- The dependency array controls when an effect re-runs: omitted (every render),
[](once), or[deps](on change). - A returned cleanup function runs before re-run and on unmount — essential for listeners and timers.
- Guard async fetches against race conditions with an ignore flag or
AbortController. - Think of effects as synchronization, not lifecycle events — and skip them when a value can be derived during render.
🎯 Quick Quiz
Question 1: An effect written as useEffect(fn, []) runs…
Question 2: Why return a cleanup function from an effect that adds a resize listener?
Question 3: Which pattern prevents a stale API response from overwriting newer data?
📚 Further Reading
- React docs — useEffect reference
- React docs — Synchronizing with Effects
- React docs — You Might Not Need an Effect
🚀 What's Next?
When state grows tangled — many interrelated fields, many ways to update it — scattered useState calls become hard to reason about. Next we'll centralize that logic with useReducer, a structured, testable way to manage complex state.
🎉 Effects unlocked!
You can now connect React to the outside world safely. On to complex state.