Contents
10 Chapter

useEffect — when to use it and when to avoid it

The definition of side effects, the dependency array, the role of cleanup, and the cases where useEffect should not be used — all in one chapter.

Chapter 9 wrapped up Part 1. The components we built so far started and ended every story inside themselves. Receiving user input, holding it in state, painting it on screen — the whole cycle was closed inside the component. Part 2 starts with this chapter. We cover useEffect, the tool the component reaches for when it has to interact with the outside world.

useEffect is as easy to misuse as it is powerful. This chapter covers not only how to use it, but also “the cases where you should not use useEffect”. And in Chapter 25 (data fetching and caching) we look at how the Next.js RSC environment replaces the most common use of useEffect + fetch.

What a side effect is #

A side effect is any work besides the component’s core role of “turning props / state into JSX”.

  • Fetching data from the server (fetch)
  • Setting timers (setTimeout, setInterval)
  • Using browser APIs (localStorage, changing document.title, etc.)
  • Initializing external libraries
  • Registering event listeners (window.addEventListener)

All of these are separate from producing the render output (JSX). They are not unrelated to the component either, though. Which data to fetch, when to turn a timer on or off — those decisions depend on the component’s props / state.

React’s recommendation is to keep this kind of work out of the component function body and put it inside useEffect instead.

Why you should not write it in the function body #

Look at the following code.

problematic code
function Profile({ userId }) {
  const [user, setUser] = useState(null);

  // 🚫 fetch directly in the function body
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => setUser(data));

  return user ? <p>{user.name}</p> : <p>Loading...</p>;
}

There are two problems.

  1. A fetch happens on every render. When setUser changes state, the component renders again, another fetch fires, setUser is called again — an infinite loop.
  2. It violates React’s principle that rendering should be fast and pure.

useEffect separates this kind of work so it runs after rendering finishes and only when necessary.

Basic useEffect usage #

src/Profile.jsx
import { useState, useEffect } from 'react';

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  return user ? <p>{user.name}</p> : <p>Loading...</p>;
}

export default Profile;

The core shape:

useEffect(() => {
  // code to run
}, [dependency, array]);
  • First argument — the effect function (the code to run)
  • Second argument — the dependency array (the effect re-runs only when these values change)

React runs the effect function after the component is painted on screen. On the next render, if the values in the dependency array are the same as before, React skips the effect; if they differ, it runs again.

The three shapes of the dependency array #

1. Empty array [] — once at mount #

runs once at component mount
useEffect(() => {
  console.log('Component appeared on screen for the first time');
}, []);

An empty array means there is nothing to depend on, so the effect runs only once. Commonly used for initial data loading and one-time initialization work.

2. Explicit dependencies [a, b] — when those values change #

whenever userId changes
useEffect(() => {
  fetch(`/api/users/${userId}`).then(/* ... */);
}, [userId]);

When userId changes, the fetch runs again. If the same value comes in, the effect does not re-run — that’s efficient. The basic rule is every prop / state you use inside the effect must appear in the dependency array. Leaving them out tends to introduce bugs where the effect closes over stale values.

3. Omitting the array entirely — every render #

every render (almost never used)
useEffect(() => {
  console.log('rendered');
});

With the dependency array left off entirely, the effect runs on every render. There is almost no real use case for this, and it is usually a recipe for accidental infinite loops, so it is better to consciously avoid it.

Tip
The dependency array is easy to forget or under-fill, so React’s ESLint plugin provides the react-hooks/exhaustive-deps rule, which catches missing dependencies automatically. Vite’s default ESLint setup includes it, so you see warnings as you write code. Don’t dismiss the warnings — following them is almost always correct.

The cleanup function #

Resources registered by an effect (timers, event listeners, subscriptions) often need to be cleaned up. Just before the component disappears from the screen, or just before the effect re-runs because a dependency changed, you have to clean up what the previous effect set up.

If the effect function returns a function, React calls that returned function at cleanup time.

src/Clock.jsx
import { useState, useEffect } from 'react';

function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);

    return () => clearInterval(id);  // cleanup
  }, []);

  return <p>{time.toLocaleTimeString()}</p>;
}

export default Clock;

When this component goes away, React calls the returned function and clearInterval tears down the timer. Without cleanup the timer keeps living after the component is gone, leaking memory and triggering unexplained bugs.

Cleanup also runs when dependencies change #

ignore the previous fetch when userId changes
useEffect(() => {
  let cancelled = false;

  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      if (!cancelled) setUser(data);
    });

  return () => {
    cancelled = true;
  };
}, [userId]);
  • Fetch starts while userId is 1
  • Before the response arrives, the user navigates and userId changes to 2
  • React runs the previous effect’s cleanup first (cancelled = true)
  • The new effect runs and fetches for 2
  • The late response for 1 arrives but is ignored because cancelled === true

Handling race conditions this way is another common use of cleanup.

Common patterns #

Fetching data #

src/UserProfile.jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Request failed');
        return res.json();
      })
      .then(data => setUser(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  if (!user) return null;
  return <p>{user.name}</p>;
}

The canonical pattern is to express the full state of an async request with three pieces of state: loading / error / data. Turning loading off in .finally() handles both success and failure consistently.

Note

In practice, it is more common to use a data-fetching library like TanStack Query than to wire up useEffect + fetch by hand. The library handles caching, retries, background sync, and so on. That said, those libraries are themselves built on top of useEffect, so understanding how the primitive works helps you understand them too.

Part 4 of this book (Modern Next.js) goes one step further. In a Server Components environment, data fetching does not happen inside a client useEffect at all — it happens directly in the body of the server component function. Chapter 25 (data fetching and caching) covers that model.

Registering event listeners #

src/WindowSize.jsx
function WindowSize() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return <p>Window width: {width}px</p>;
}

addEventListenerremoveEventListener must be paired, so cleanup is required.

Syncing external state like document.title #

syncing document title with the count
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

  // ...
}

document.title lives outside React’s domain, so syncing it with our state requires an effect.

localStorage sync #

persisting a setting to localStorage
function Settings() {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') ?? 'light';
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
  }, [theme]);

  // ...
}

Passing a function to useState’s initial value runs that function only on the first mount. That prevents localStorage.getItem from being called on every render. After that, the effect saves the new value whenever theme changes.

When you should not use useEffect #

This is a point the official React docs underline. Don’t use useEffect for work that can be done by plain computation. Half of useEffect misuse starts here.

1. A value computed from other state #

wrong — going through effect for no reason
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
right — just a variable
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`;

fullName is always derivable from firstName / lastName. Holding it as its own state pushes the burden of keeping the two in sync onto us. Leaving it as a plain variable always gives the correct value automatically. This is the same idea as “Single Source of Truth” in Chapter 11 (lifting state up).

2. Work that belongs in an event handler #

wrong — running click handling through effect
const [submitted, setSubmitted] = useState(false);

useEffect(() => {
  if (submitted) {
    sendAnalytics('submit');
  }
}, [submitted]);

function handleSubmit() {
  setSubmitted(true);
}
right — just in the handler
function handleSubmit() {
  sendAnalytics('submit');
}

“When a click happens, do X” belongs directly in the click handler. There is no reason to detour through state and an effect.

3. One-time initialization #

wrong — initialization via effect
function App() {
  useEffect(() => {
    initializeAnalytics();
  }, []);
  // ...
}
better — outside the component
initializeAnalytics();

function App() {
  // ...
}

Initialization code that needs to run only once across the entire app has no reason to live inside a component. Running it once at the module’s top level is enough.

The test #

To decide whether useEffect is really necessary, ask one question.

Does this work involve the outside world (server, timer, DOM API, browser API)?

If not, you almost certainly don’t need useEffect.

Common mistakes #

Missing dependencies #

bug — userId is missing
useEffect(() => {
  fetch(`/api/users/${userId}`).then(/* ... */);
}, []);

With an empty array, the effect fetches only with the userId from the first render and never refetches when userId changes. ESLint catches this — follow the warning.

Infinite setState inside an effect #

infinite loop
useEffect(() => {
  setCount(count + 1);  // 🚫 mutating count inside an effect with [count]
}, [count]);

Changing state triggers a render, the dependency has changed so the effect runs again, which changes state again — an endless loop. An effect’s job is to change the outside world, not to mutate its own state perpetually.

Try it yourself #

Let’s build a simple clock + page title sync component.

src/ClockTitle.jsx:

src/ClockTitle.jsx
import { useState, useEffect } from 'react';

function ClockTitle() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  useEffect(() => {
    document.title = `Current time: ${time.toLocaleTimeString()}`;
  }, [time]);

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>{time.toLocaleTimeString()}</h2>
      <p>Check that the browser tab title changes along with the clock.</p>
    </div>
  );
}

export default ClockTitle;

Once saved, the clock updates every second and the browser tab title changes along with it. You can see two useEffects doing different things side by side. The first updates the time once a second; the second syncs the tab title each time the time changes.

Exercises #

  1. Build a WindowSize component that displays window.innerWidth and window.innerHeight and updates automatically as the window is resized. Register a resize event listener and release it in cleanup. You also need to read the initial values correctly at mount time.
  2. Practice spotting useEffect misuse. Given the following two variables, decide which side needs useEffect. (a) Computing fullName from firstName and lastName. (b) Fetching info from /api/users/:id whenever userId changes. (a) is done without useEffect, as a plain variable; (b) is done with useEffect + cleanup.
  3. A self-cleaning timer. Use useState for seconds, and increment it by 1 every second from mount. When the component disappears (e.g., when the parent conditionally removes it), confirm in the console that setInterval is cleaned up so there is no memory leak. While you are at it, observe React strict mode causing the effect to run twice in dev.

In one line: useEffect(fn, deps) runs fn whenever deps change. [] means once at mount, [a] means whenever a changes, and omitting it means every render. Returning a function gives you cleanup. Every prop / state used inside the effect must be in the dependency array. Plain computation, event handling, and one-time initialization are not the place for useEffect.

Next chapter #

Every component we have built so far had its own state. But what about when two sibling components need to share the same state? In the next Chapter 11 Lifting state up, we cover lifting state up, the core pattern for exactly that case.

X