React components have one job: return JSX based on props and state. Anything else — fetching data, updating the document title, setting up a timer — is a side effect. It’s something that happens outside the render cycle.
Copy
function UserList() { const [users, setUsers] = useState([]); // This is a side effect — fetching data from an API // Where does it go? useEffect.}
useEffect is the hook for running side effects. In this course, you’ll use it mostly for fetching data and syncing with browser APIs (like document title or timers).
Python mental model: your component function is like a function React may call many times to calculate UI. Keep that part “pure” (return JSX from props/state). useEffect is where you register code that should run after render to sync with the outside world (network, browser APIs, timers).
The dependency array controls when the effect runs:
Python mental model: the dependency array is the list of values React watches. You’re telling React, “re-run this sync code when these inputs change.”
Copy
// Runs once — when the component mountsuseEffect(() => { fetchUsers();}, []);// Runs when userId changesuseEffect(() => { fetchUser(userId);}, [userId]);// Runs when either userId or role changesuseEffect(() => { fetchUserData(userId, role);}, [userId, role]);// ⚠️ Runs on EVERY render (usually a mistake)useEffect(() => { console.log("Rendered!");}); // No array at all
Dependency array
When it runs
[]
Once, when component mounts
[userId]
On mount + whenever userId changes
[a, b]
On mount + whenever a or b changes
(omitted)
Every single render (rarely wanted)
In development, React.StrictMode may run mount effects twice to help reveal bugs. For a simple useEffect(..., []) data-fetch example, that can mean two requests in development unless you guard against it. Production does not do the extra development check.
[] is a common beginner pattern for “run on mount” effects (like an initial fetch), but it’s not the default for every effect. As a rule: put every prop/state value your effect uses into the dependency array. Omitting the array entirely causes the effect to run on every render, which is usually a bug.
This is the pattern you’ll use most often — load data when a component first appears:
Copy
import { useState, useEffect } from 'react';function UserList() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function loadUsers() { try { const response = await fetch("http://localhost:8000/api/users"); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); setUsers(data); } catch (err) { setError(err.message); } finally { setLoading(false); } } loadUsers(); }, []); // Empty array = fetch once on mount if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; return ( <ul> {users.map(user => ( <li key={user.id}>{user.name} — {user.email}</li> ))} </ul> );}
Notice the async function is defined inside the useEffect callback, not as the callback itself. This is because useEffect callbacks can’t be async directly — they need to return either nothing or a cleanup function.
The function you return from useEffect runs when the component unmounts (disappears) or before the effect re-runs:
Copy
useEffect(() => { // Setup: runs when component mounts (or dependencies change) const subscription = connectToWebSocket(userId); // Cleanup: runs when component unmounts (or before next run) return () => { subscription.close(); };}, [userId]);
Cleanup prevents memory leaks and stale data. Common cleanup tasks:
Clearing timers (clearInterval, clearTimeout)
Closing WebSocket connections
Cancelling pending requests
Removing event listeners
You won’t need cleanup for most simple fetch-on-mount examples. Cleanup is mainly for subscriptions, timers, and event listeners. In development, StrictMode may re-run effects and expose problems earlier, but don’t rely on it to catch every cleanup bug.
// ❌ Infinite loop: fetches on every renderuseEffect(() => { fetch("/api/users") .then(r => r.json()) .then(data => setUsers(data)); // setUsers triggers re-render → effect runs again → ...});// ✅ Add empty dependency arrayuseEffect(() => { fetch("/api/users") .then(r => r.json()) .then(data => setUsers(data));}, []); // Only runs once
Forgetting the dependency array creates an infinite loop: render → effect → state update → re-render → effect → … Your browser tab will freeze and your API will get hammered.
Making useEffect callback async directly
Copy
// ❌ Wrong: useEffect callback can't be asyncuseEffect(async () => { const response = await fetch("/api/users"); const data = await response.json(); setUsers(data);}, []);// ✅ Correct: define async function inside, then call ituseEffect(() => { async function loadUsers() { const response = await fetch("/api/users"); const data = await response.json(); setUsers(data); } loadUsers();}, []);
useEffect callbacks must return either nothing or a cleanup function. An async function returns a Promise, which React doesn’t know how to handle. Define the async function inside the effect and call it immediately.
Object or array as dependency (re-runs every render)
Copy
// ❌ Runs on every render because {} !== {}const options = { page: 1, limit: 10 };useEffect(() => { fetchUsers(options);}, [options]); // New object every render!// ✅ Use primitive values as dependenciesuseEffect(() => { fetchUsers({ page, limit });}, [page, limit]); // Primitives are compared by value
Objects and arrays are compared by reference, not by value. {a: 1} !== {a: 1}. Use primitive values (strings, numbers, booleans) in the dependency array whenever possible.
Think of useEffect in terms of synchronization, not lifecycle:
Copy
"Keep this side effect synchronized with these values"
useEffect(fn, []) — synchronize with nothing (run once)
useEffect(fn, [userId]) — synchronize with userId (re-run when it changes)
useEffect(fn, [a, b]) — synchronize with a and b
If you’re coming from Python or other frameworks, resist thinking in “component lifecycle” terms (mount, update, unmount). Think instead: “this effect should run when these values change.” The dependency array is the key to understanding useEffect.
Also remember: useEffect callbacks are closures. They capture values from the render where they were created. That’s why missing a dependency can make an effect use an old value (“stale closure”).
You can fetch data and manage side effects. Now let’s learn how to conditionally show different UI based on that data — loading spinners, error messages, and content.