Lesson 5

useEffect

Home › Courses › React › Lesson 5
⚛️ Lesson 5 · 20 minutes

useEffect.
Sync with the world outside React.

Rendering is pure: given state, React produces UI. But real apps need to do things that aren't rendering — fetch data from an API, subscribe to a websocket, set a document title, manage a timer. That's what useEffect is for. It runs side effects after render and cleans up before the next one. This is the hook beginners most often misuse. This lesson teaches the correct mental model, the dependency array, and the cleanup pattern that prevents every bug.

~20 minutes Intermediate React 19
React path ← Lesson 4 Basics Dependencies Data fetching Cleanup Pitfalls Challenge

useEffect(() => { ... }) runs after render. The second argument — the dependency array — controls when it runs: [] runs once on mount, [x] runs when x changes, omitted runs after every render. The function you return is the cleanup — it runs before the next effect and on unmount. Use useEffect for external sync (fetching, subscriptions, DOM, timers). Don't use it for derived state — compute in render instead.

Before this lesson: complete Lesson 4. You need to understand state and event handlers first.

🎯 The basic pattern

Runs after render. Never during. That's the entire concept.

React's render phase must be pure — no side effects, no API calls, no DOM manipulation. useEffect lets you run those effects after React has committed the render to the DOM. This ordering is important: the effect can access the rendered DOM, and it doesn't block the paint.

Basic useEffect
import { useEffect, useState } from 'react';

function DocumentTitle({ count }) {
useEffect(() => {
// Runs AFTER the render is committed to the DOM
document.title = Count: ${count}`;
});

return 

Check the browser tab — it says "{count}"

; } // Order of operations: // 1. React calls DocumentTitle() → returns JSX // 2. React commits the JSX to the DOM // 3. React runs the useEffect callback // 4. document.title updates // // The effect never runs during render. // This is why side effects belong in useEffect, // not in the component body. // ───────────────────────────────────────── // ❌ WRONG — side effect in render function Broken({ count }) { document.title = Count:${count}`; // This works sometimes but is a bug: // - Fires on every render, including ones React discards // - Runs during StrictMode double-render in dev // - Runs on the server during SSR (no document) // - Makes the render impure return

{count}

; } // ✅ CORRECT — side effect in useEffect function Fixed({ count }) { useEffect(() => { document.title = Count: ${count}`; }); return

{count}

; }

Runs after render

Not during, not before. The effect runs after React has painted. This lets it access the actual DOM and not block the first paint.

Never async itself

useEffect(async () => {}) is a bug — effects can't be async. Define an async function inside and call it: useEffect(() => { async function load() {} load(); }, []).

What goes here

API calls, subscriptions, timers, DOM manipulation, logging, localStorage sync, third-party library init. Things that aren't part of computing the UI.

What doesn't

Derived state (compute in render). Event handlers (use onClick). Anything that runs in response to user action, not render.

If you're putting something in useEffect, ask: does this need to run in response to a render, or a user action? If it's a user action (click, submit, keypress), use an event handler. If it's a render (component mounted, a dependency changed), use useEffect. This one question prevents 90% of useEffect misuse. Fetching data when a component mounts → useEffect. Fetching data when a button is clicked → event handler.

🔗 The dependency array — when to re-run

Three cases. Each means something specific. Get this right and effects stop being mysterious.

Three dependency patterns
// 1. NO dependency array — runs after EVERY render
useEffect(() => {
console.log('Ran after render');
});
// Rarely what you want. Usually a bug or oversight.
// Runs: initial render, every state change, every prop change.

// 2. EMPTY array [] — runs ONCE after first render
useEffect(() => {
console.log('Component mounted');
}, []);
// Use for: fetch data once, set up a subscription,
// initialize a third-party library. The effect runs
// after the first render and never again (unless unmounted).

// 3. SPECIFIC dependencies [x, y] — runs when x or y changes
useEffect(() => {
console.log('Count changed to', count);
}, [count]);
// Runs: after first render (initial), and every time
// count changes. If count stays the same across renders,
// the effect doesn't re-run.
//
// React compares dependencies by reference (Object.is).
// Primitives (numbers, strings, booleans) compare by value.
// Objects and arrays compare by reference — a new {} or []
// every render triggers the effect every render.

// ─────────────────────────────────────────

// Example: fetch data when userId changes
function UserProfile({ userId }) {
const [user, setUser] = useState(null);

useEffect(() => {
async function load() {
const res = await fetch(/api/users/${userId}`);
const data = await res.json();
setUser(data);
}
load();
}, [userId]);
// Runs when userId changes. Refetches the user.
// If userId stays the same, no refetch.
// This is the standard "fetch on prop change" pattern.

if (!user) return 

Loading...

; return

{user.name}

; }

No array

Runs after every render. Rarely what you want — usually a symptom of misunderstanding or a bug. Most effects should have dependencies.

Empty []

Runs once on mount, cleans up on unmount. "Set it up once, tear it down once." Use for one-time initialization, subscriptions, and initial data fetch.

[values]

Runs when any of these values changes. React compares each value to the previous render. New value → effect runs. Same value → skip.

Reference equality

React uses Object.is for comparison. Primitives compare by value. Objects and arrays compare by reference — inline {} or [] creates a new reference every render → effect runs every render.

Missing dependencies = stale closures. If you use count inside an effect but don't include it in the dependency array, the effect captures the initial value of count forever. The effect runs, but with old data. This is the most confusing useEffect bug. Fix: install eslint-plugin-react-hooks — it warns you exactly which dependencies are missing. Never silence those warnings without understanding why.

🌐 Data fetching — the real-world pattern

Loading, error, data. Three states every fetch needs. Plus cleanup to prevent races.

Complete fetch pattern
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
// AbortController lets us cancel if the component unmounts
// or if userId changes before the fetch completes
const controller = new AbortController();

async function load() {
setLoading(true);
setError(null);

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();
setUser(data);
} catch (err) {
if (err.name === 'AbortError') return; // cancelled — ignore
setError(err.message);
} finally {
setLoading(false);
}
}

load();

// Cleanup — cancel the fetch if the effect re-runs or unmounts
return () => controller.abort();
}, [userId]);

if (loading) return 

Loading...

; if (error) return

Error: {error}

; return

{user.name}

; }

Three states

Always handle loading, error, and data. Every fetch can fail, can take time, and can succeed. Showing only "data" means a blank screen for the first second and no feedback on failure.

AbortController

Cancels the fetch if the effect re-runs (userId changed) or the component unmounts. Prevents "can't set state on unmounted component" warnings and stale-data overwrites.

Race conditions

Without abort, two rapid userId changes can finish out of order — the older fetch overwrites the newer one. Abort kills the old request so only the newest result lands.

Check res.ok

fetch doesn't throw on 404/500. It resolves with a failed response. Always check res.ok and throw manually if false. Otherwise errors look like empty successes.

This is the pattern every production React app uses. Three states, AbortController, check res.ok, cleanup return. Every data-fetching hook in every React tutorial builds on this shape. React Query, SWR, and TanStack Query all automate these parts — but they all implement this pattern internally. Build it once by hand, and you'll understand every fetching library forever.

🧹 Cleanup — clean up what you set up

Return a function from useEffect. React calls it before the next effect and on unmount.

Cleanup patterns
// 1. Event listener
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}

window.addEventListener('resize', handleResize);

// Cleanup — remove the same listener
return () => window.removeEventListener('resize', handleResize);
}, []);
// Without cleanup, every re-mount adds another listener.
// Memory leak. The handler fires multiple times per resize.

// ─────────────────────────────────────────

// 2. Timer
useEffect(() => {
const id = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);

return () => clearInterval(id);
}, []);
// Without cleanup, the interval keeps running after unmount.
// Updates state on an unmounted component. React warns.

// ─────────────────────────────────────────

// 3. Subscription (e.g., WebSocket, Firebase)
useEffect(() => {
const unsubscribe = store.subscribe('messages', (msg) => {
setMessages(prev => [...prev, msg]);
});

return unsubscribe;
}, []);
// Unsubscribe on unmount. Without it, the store keeps sending
// messages to a component that no longer exists.

// ─────────────────────────────────────────

// 4. Fetch (see previous section for full example)
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});

return () => controller.abort();
}, [url]);

Cleanup runs when

Before the effect runs again (when deps change), and once when the component unmounts. It's the "tear down" that pairs with the "set up."

Same reference

For event listeners, the cleanup must remove the exact function that was added. So define the handler outside the listener call, then use the same variable in both places.

Return undefined is fine

If the effect doesn't need cleanup, don't return anything. Returning undefined is the same as returning nothing. Only return a function when there's something to tear down.

Async effects

You can't return from an async function inside useEffect (you'd return a Promise). Define cleanup outside the async function: const controller = new AbortController(); async function load() {...}; load(); return () => controller.abort();

If you set up something, clean it up. Every event listener, interval, timeout, subscription, and fetch controller needs a cleanup. React doesn't do this for you — but it will warn you with "Can't perform a React state update on an unmounted component" if you forget. Every warning is a leak. Every leak is a bug waiting to happen (or already happening). Treat cleanup as required, not optional.

⚠️ useEffect pitfalls

The four mistakes that cause 90% of useEffect bugs.

1. useEffect for derived state

useEffect(() => setTotal(items.length), [items]) — computing a value and storing it in state. Fix: const total = items.length in the render. No effect, no state, no bug.

2. Missing dependencies

Using count inside an effect but not adding it to [] — the effect captures the initial count forever. Fix: install eslint-plugin-react-hooks. It tells you exactly what's missing.

3. Infinite loop

useEffect(() => setCount(count + 1), [count]) — effect changes count, count is in deps, effect re-runs, forever. Fix: don't set state that's in the dependencies. Or remove the state from deps if it shouldn't trigger.

4. Async function directly in useEffect

useEffect(async () => {...}) — async functions return Promises, and effect can't return a Promise. Fix: useEffect(() => { async function load() {...}; load(); }, []).

5. Object/array dependency

useEffect(() => {...}, [{ config }]) — new object every render = effect runs every render. Fix: destructure to primitives ([config.id, config.name]) or use useMemo.

6. Not cleaning up

Event listeners, intervals, subscriptions — all leaked if not cleaned up. Fix: return a cleanup function. Every time.

7. Conditional useEffect

if (x) { useEffect(...) } — breaks React's hook order. Fix: call unconditionally, put condition inside the effect body.

8. Using effect for event handlers

Fetching data when a button is clicked should be an onClick handler, not a useEffect. Effects are for render-triggered side effects, not user-triggered.

🏆 Challenge

Three tasks. Each exercises a different useEffect pattern.

Task 1 — Live clock

Build a component that displays the current time and updates every second:

1. State for the current time
2. useEffect with setInterval to update the time
3. Cleanup that clears the interval
4. Display in the format "14:32:07"

Test by unmounting the component — there should be no warning in the console about setting state on an unmounted component. That's the sign your cleanup works.

Task 2 — Data fetcher with all three states

Build a PostList component that:

1. Fetches posts from https://jsonplaceholder.typicode.com/posts
; 2. Has state for posts, loading, and error
3. Shows "Loading..." while loading, "Error: ..." on failure, and the list of posts on success
4. Uses AbortController to cancel on unmount
5. Checks res.ok before parsing JSON

Test by temporarily changing the URL to an invalid one — you should see the error state, not a blank screen. Test by unmounting mid-fetch — you should see no warnings.

Task 3 — Search with debounce

Build a search component that fetches from an API as the user types, but waits 300ms after typing stops to avoid firing a request on every keystroke:

1. State for the query
2. useState for the debounced query (updates 300ms after query changes)
3. useEffect with a setTimeout that updates debouncedQuery
4. Cleanup that clears the timeout when query changes
5. Another useEffect that fetches when debouncedQuery changes

Without the cleanup, rapid typing queues up multiple setTimeouts that all fire. With it, only the last one fires. This is the standard debounce pattern — every search box uses it.

❓ Frequently asked questions

What is useEffect in React?

useEffect is a hook that runs side effects after render. Side effects are things that aren't part of rendering: fetching data, setting up subscriptions, manipulating the DOM directly, setting timers. The effect runs after React has committed the render to the DOM, so it doesn't block the paint.

What does the dependency array do?

The second argument to useEffect is an array of dependencies. React compares the current values to the previous render's values. If any dependency changed, the effect runs again. No array means it runs after every render. Empty array means once on mount.

What is the cleanup function in useEffect?

Return a function from useEffect to clean up when the effect is about to run again or the component unmounts. Used for removing event listeners, cancelling subscriptions, clearing timers, and aborting fetches. If you set up something, clean it up.

Why does my useEffect run infinitely?

Because it updates state that's in its dependency array. The effect changes state, which triggers the effect again, which changes state, forever. Fix: either remove the state from dependencies if it shouldn't trigger a re-run, or don't update state in the effect.

What is the difference between useEffect and event handlers?

Event handlers run in response to user actions (click, submit, keypress). useEffect runs in response to renders — specifically, when the component mounts or its dependencies change. Use event handlers for actions the user takes. Use useEffect for side effects that should happen as a consequence of rendering.

How do I fetch data with useEffect?

useEffect with async function inside: useEffect(() => { async function load() { const res = await fetch(url); const data = await res.json(); setData(data) } load(); }, [url]). For cleanup, use an AbortController to cancel the fetch if the component unmounts.

Should I use useEffect for derived state?

No. If you're computing a value from existing state or props, compute it in the render function — not in an effect. Deriving in render is faster, simpler, and can't get out of sync. useEffect is for synchronizing with external systems, not for computing.

Can I call useEffect conditionally?

No. Hooks must be called in the same order on every render. Writing if (x) { useEffect(...) } breaks React's internal bookkeeping. Fix: always call useEffect unconditionally. Put the condition inside the effect body.

🔗 Continue your React path

🌐 External resources

← Back to React track
You're in the React track · Lesson 5