π The useEffect Hook
Rendering is about turning data into UI β a pure calculation with no outside contact. But real components also need to reach out: fetch data from an API, start a timer, listen for a keypress, sync with the browser. These are side effects, and useEffect is the Hook that runs them safely, after the screen is painted, and cleans them up when you're done.
Week 4 · Day 2 (Tuesday: State and Lifecycle) · Lecture 2
π― Learning Objectives
By the end of this lesson, you will be able to:
- Define a side effect and recognize why it belongs in
useEffect - Write an effect and control its timing with the dependency array
- Distinguish the three dependency patterns: none,
[], and[deps] - Return a cleanup function to tear down timers, listeners, and subscriptions
- Fetch data on mount and cancel stale requests
- Avoid the two classic bugs: infinite loops and stale closures
Estimated Time: 70 minutes
Practice: Build a live clock, a window-size tracker, and a debounced search β each with proper cleanup.
In This Lesson
What Is a Side Effect?
A component's render is meant to be pure: given the same props and state, it returns the same JSX and touches nothing outside itself. Anything that breaks that purity β talking to a server, reading window, starting a timer, subscribing to a socket β is a side effect.
Picture a chef. Cooking the dish from the ingredients on the counter is the render. Ordering more ingredients, setting a kitchen timer, or turning on the extractor fan are side effects β interactions with the world beyond the recipe. You don't do them mid-chop; you do them at the right moment. useEffect is React's "right moment": it runs your effect after the render is committed to the screen.
π‘ Why after render, not during?
Running a fetch or a timer during render would block the UI and could fire repeatedly as React re-renders. By deferring effects until after the DOM is painted, React keeps rendering fast and predictable, and your effect sees the real, on-screen UI.
useEffect Syntax
useEffect takes two arguments: a setup function (your effect) and an optional dependency array. The setup runs after render; if it returns a function, that returned function is the cleanup.
import { useEffect } from 'react';
useEffect(() => {
// 1. Setup β runs after the render is committed
// Do your side effect here.
return () => {
// 2. Cleanup (optional) β runs before the next effect
// and when the component unmounts.
};
}, [/* 3. dependencies */]);
A first, minimal effect that runs after every render:
import { useState, useEffect } from 'react';
function Logger() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('Rendered β count is now', count);
}); // no dependency array β runs after EVERY render
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
The Dependency Array
The second argument controls when the effect re-runs. This is the single most important thing to understand about useEffect. There are three patterns.
| What you write | When the effect runs | Typical use |
|---|---|---|
| (omitted) | After every render | Rare β usually a mistake |
[] | Once, after the first render (mount) | Fetch initial data, set up a subscription |
[a, b] | On mount, then whenever a or b changes | Re-fetch when an id or query changes |
1. Run once, on mount
function Welcome() {
useEffect(() => {
console.log('Mounted β runs exactly once');
}, []); // empty array β mount only
return <h1>Welcome!</h1>;
}
2. Run when a value changes
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // re-runs whenever userId changes
if (!user) return <p>Loadingβ¦</p>;
return <p>{user.name}</p>;
}
β οΈ Include every value the effect uses
Your dependency array must list every prop, state, or variable the effect reads. Leaving one out gives you a stale closure β the effect keeps using an old value. The eslint-plugin-react-hooks "exhaustive-deps" rule flags these for you; trust it.
Cleanup Functions
Anything you start in an effect, you must stop: intervals, event listeners, subscriptions, open connections. Return a function from your effect and React runs it as cleanup β before the effect runs again, and once more when the component unmounts. Think of it as tidying your workspace before the next task and before you leave.
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// Setup: start the interval
const id = setInterval(() => {
setSeconds(s => s + 1); // functional update β always fresh
}, 1000);
// Cleanup: stop it so it doesn't leak or double up
return () => clearInterval(id);
}, []); // set up once on mount
return <p>Seconds: {seconds}</p>;
}
function WindowSize() {
const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });
useEffect(() => {
const handleResize = () =>
setSize({ w: window.innerWidth, h: window.innerHeight });
window.addEventListener('resize', handleResize);
// Cleanup: remove the listener to avoid a memory leak
return () => window.removeEventListener('resize', handleResize);
}, []);
return <p>{size.w} Γ {size.h}</p>;
}
β οΈ Forgetting cleanup = memory leaks & duplicate work
Without clearInterval, every re-mount stacks another ticking interval. Without removeEventListener, listeners pile up on window and keep firing after the component is gone β often throwing "can't set state on an unmounted component" warnings.
The Effect Lifecycle
Trace one effect through a component's life: it sets up after the first render, and if it has dependencies, it cleans up and re-runs each time those change, then cleans up one final time on unmount.
π‘ One effect, one job
Each useEffect should handle a single concern. Need to fetch data and listen for resizes? Use two effects. They're independent, easier to read, and each gets its own focused dependency array and cleanup.
Data Fetching
The most common effect: load data when the component mounts (or when an id changes). Handle three states β loading, error, success β and guard against a subtle bug where a slow response arrives after the component has moved on.
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false; // guard against stale responses
async function load() {
try {
setLoading(true);
const res = await fetch('https://api.example.com/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!ignore) setUsers(data); // only apply if still relevant
} catch (err) {
if (!ignore) setError(err.message);
} finally {
if (!ignore) setLoading(false);
}
}
load();
// Cleanup: mark this run stale if the component re-runs/unmounts
return () => { ignore = true; };
}, []); // fetch once on mount
if (loading) return <p>Loadingβ¦</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
β
The ignore flag prevents race conditions
If userId changes quickly, request A might resolve after request B, overwriting fresh data with stale data. The cleanup sets ignore = true for the outgoing effect, so its late response is discarded. A modern alternative is AbortController to actually cancel the fetch.
π‘ In real projects, reach for a data library
Manual fetch-in-effect is great for learning, but production apps usually use TanStack Query or a framework's data loader, which handle caching, retries, and race conditions for you. Understanding the effect underneath makes those tools far less magical.
More Common Effects
Subscriptions
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const sub = subscribeToChat(roomId, (msg) =>
setMessages(prev => [...prev, msg])
);
return () => sub.unsubscribe(); // resubscribe cleanly when roomId changes
}, [roomId]);
return messages.map((m, i) => <p key={i}>{m}</p>);
}
Debounced search
Wait until the user pauses typing before hitting the API. The cleanup cancels the pending timeout on every keystroke, so only the last one fires.
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) { setResults([]); return; }
const id = setTimeout(() => {
searchAPI(query).then(setResults);
}, 300); // wait 300ms after the last keystroke
return () => clearTimeout(id); // cancel if query changes first
}, [query]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Searchβ¦" />
<ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>
</div>
);
}
Syncing to localStorage
function Notes() {
const [text, setText] = useState(() => localStorage.getItem('notes') || '');
useEffect(() => {
localStorage.setItem('notes', text); // persist on every change to text
}, [text]);
return <textarea value={text} onChange={(e) => setText(e.target.value)} />;
}
Dependency Pitfalls
1. Infinite loops
If an effect sets state that's in its own dependency array, it re-runs forever. Break the cycle with the functional update form or a correct dependency list.
// β Infinite loop: effect sets count, count is a dependency, repeat forever
useEffect(() => {
setCount(count + 1);
}, [count]);
// β
If you truly need to increment once, do it without depending on count,
// or rethink whether this belongs in an effect at all.
2. Stale closures
// β count is captured once and never updates β always 0 + 1
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // count missing from deps
// β
functional update reads the latest value, no dependency needed
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
3. Object & function dependencies change every render
Objects and functions created inside the component are new on every render, so an effect that depends on them re-runs every time. Depend on primitive values, move the object out, or memoize with useMemo/useCallback.
// β filters is a brand-new object each render β effect runs every render
function UserSearch() {
const filters = { active: true, role: 'admin' };
useEffect(() => { fetchUsers(filters); }, [filters]);
}
// β
depend on the primitive fields instead
function UserSearch() {
const active = true;
const role = 'admin';
useEffect(() => { fetchUsers({ active, role }); }, [active, role]);
}
Practice & Quiz
ποΈ Exercise 1: A ticking clock
Goal: Build a Clock that displays the current time and updates every second. Clean up the interval on unmount.
function Clock() {
// TODO: store the current time in state
// TODO: start an interval that updates it every second
// TODO: clear the interval in cleanup
}
π‘ Hint
Initialize state with new Date(). In an effect with [] deps, setInterval to call setTime(new Date()) each second, and return () => clearInterval(id).
β Solution
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
return <p>{time.toLocaleTimeString()}</p>;
}
ποΈ Exercise 2: Document title sync
Goal: A counter whose value is also shown in the browser tab title (document.title), staying in sync as the count changes.
β Solution
function TitleCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Clicked ${count} times`;
}, [count]); // re-run whenever count changes
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
π― Quick Quiz
Question 1: When does an effect with an empty dependency array [] run?
Question 2: What is the returned function inside an effect for?
Question 3: An interval effect logs the wrong count every time. The most likely fix is:
Best Practices & Pitfalls
β Do
- Give every effect a correct, complete dependency array
- Return cleanup for anything you subscribe to, listen on, or schedule
- Keep one effect focused on one concern β split unrelated logic
- Use functional updates inside timers/listeners to dodge stale closures
- Guard async responses with an
ignoreflag orAbortController
β Don't
- Set state a dependency reads without a plan β that's an infinite loop
- Omit dependencies to "make it run once" if the effect uses changing values
- Put derived values in an effect when you could compute them during render
- Forget cleanup β leaked intervals and listeners are a top React bug
β οΈ Not everything needs an effect
If a value can be calculated from props and state during render, do that instead of storing it in state and syncing it with an effect. Effects are for reaching outside React β DOM, network, timers β not for transforming data you already have.
Summary
π Key Takeaways
- Side effects reach outside the component β fetching, timers, listeners, DOM β and run in
useEffect - Effects run after the render is committed to the screen
- The dependency array controls re-runs: omitted = every render,
[]= mount only,[deps]= when deps change - Return a cleanup function to tear down anything you set up
- Watch for infinite loops and stale closures; functional updates fix most closure bugs
- Include every value the effect uses in its dependency array β let the linter help
π Additional Resources
- react.dev β
useEffectreference - react.dev β Synchronizing with Effects
- react.dev β You Might Not Need an Effect
- react.dev β Lifecycle of Reactive Effects
π What's Next?
You've met state and effects individually. Next we zoom out to see how they combine over a component's whole life β mounting, updating, and unmounting β in Component Lifecycle, where useEffect reveals itself as the modern replacement for the old class lifecycle methods.
π Your components can talk to the world now!
Fetching, timing, listening, syncing β with cleanup done right, you can wire React to anything.