Skip to main content

What are side effects?

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.
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).

Basic useEffect

import { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    // This runs after the component renders
    console.log("Component mounted!");
  }, []); // Empty array = run once on mount

  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
useEffect takes two arguments:
  1. A function — the code to run (the effect)
  2. A dependency array — when to re-run it

The dependency array

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.”
// Runs once — when the component mounts
useEffect(() => {
  fetchUsers();
}, []);

// Runs when userId changes
useEffect(() => {
  fetchUser(userId);
}, [userId]);

// Runs when either userId or role changes
useEffect(() => {
  fetchUserData(userId, role);
}, [userId, role]);

// ⚠️ Runs on EVERY render (usually a mistake)
useEffect(() => {
  console.log("Rendered!");
}); // No array at all
Dependency arrayWhen 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.

Fetching data on mount

This is the pattern you’ll use most often — load data when a component first appears:
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.

Fetching based on a prop or state value

When the data depends on a value (like a user ID from the URL), include it in the dependency array:
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function loadUser() {
      setLoading(true);
      try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        setUser(await response.json());
      } catch (err) {
        setUser(null);
      } finally {
        setLoading(false);
      }
    }

    loadUser();
  }, [userId]); // Re-fetch when userId changes

  if (loading) return <p>Loading...</p>;
  if (!user) return <p>User not found</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}
When userId changes (e.g., navigating from one user profile to another), the effect runs again and fetches the new user.

Other common side effects

Updating the document title

function ProductPage({ product }) {
  useEffect(() => {
    document.title = `${product.name} — My Store`;
  }, [product.name]);

  return <h1>{product.name}</h1>;
}

Setting up a timer

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);

    // Cleanup: clear the interval when component unmounts
    return () => clearInterval(interval);
  }, []);

  return <p>Elapsed: {seconds}s</p>;
}

Cleanup function

The function you return from useEffect runs when the component unmounts (disappears) or before the effect re-runs:
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.

Common mistakes

// ❌ Infinite loop: fetches on every render
useEffect(() => {
  fetch("/api/users")
    .then(r => r.json())
    .then(data => setUsers(data)); // setUsers triggers re-render → effect runs again → ...
});

// ✅ Add empty dependency array
useEffect(() => {
  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.
// ❌ Wrong: useEffect callback can't be async
useEffect(async () => {
  const response = await fetch("/api/users");
  const data = await response.json();
  setUsers(data);
}, []);

// ✅ Correct: define async function inside, then call it
useEffect(() => {
  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.
// ❌ Runs on every render because {} !== {}
const options = { page: 1, limit: 10 };
useEffect(() => {
  fetchUsers(options);
}, [options]); // New object every render!

// ✅ Use primitive values as dependencies
useEffect(() => {
  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.

The useEffect mental model

Think of useEffect in terms of synchronization, not lifecycle:
"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”).

What’s next?

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.

Conditional rendering

Show different UI based on state and conditions