React
useEffect
React Hooks
Data Fetching
TypeScript
Frontend

useEffect in React: Dependencies, Cleanup, and Timing

How useEffect really works: the dependency array, cleanup functions, StrictMode double runs, async effects, and the cases where you should not use it.

11 min read
Chamikara Nayanajith

Most React bugs I have had to debug in someone else's codebase live inside a useEffect. Not because the hook is badly designed, but because it gets used as a general purpose "run this code" escape hatch, when what it actually does is narrower and stranger: it synchronises your component with something outside React, and it does so after the browser has already painted.

Get that sentence right and the dependency array, the cleanup function and the double invocation in development all stop being mysterious.

What useEffect is actually for

An effect connects your component to an external system and disconnects it again. A WebSocket. A setInterval. A subscription to the browser's resize event. An imperative third-party chart library that knows nothing about React.

That is the full list of good reasons. If the code you are about to put in an effect only reads and writes React state, it almost certainly belongs somewhere else, and I will come back to where.

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

function WindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    // Connect to an external system.
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', onResize);

    // Disconnect from it.
    return () => window.removeEventListener('resize', onResize);
  }, []);

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

Two halves, symmetrical. Connect, then disconnect. Every effect that is pulling its weight has that shape, and effects that do not have that shape are usually the ones causing trouble.

The dependency array

The second argument to useEffect tells React when to run the effect again. It has three meaningfully different forms, and people routinely pick the wrong one.

tsx
useEffect(() => { /* ... */ });          // after every single render
useEffect(() => { /* ... */ }, []);      // once, after the first render
useEffect(() => { /* ... */ }, [userId]); // whenever userId changes

The first form is almost never what you want and is worth treating as a typo. The second is the one people reach for by reflex. The third is the one that is usually correct.

The array is not a list of triggers

This is the mental model that causes the most damage. Developers read the dependency array as "the things that should cause this to re-run", as though it were an event subscription. It is not. It is a list of every reactive value the effect body reads, and React uses it to skip work it knows is unnecessary.

The difference matters when you leave something out. Omitting a dependency does not mean "ignore changes to this". It means the effect keeps running with a stale closure over the old value, which produces bugs that only appear on the second interaction and are miserable to trace.

tsx
function Search({ query }: { query: string }) {
  const [results, setResults] = useState<Result[]>([]);

  useEffect(() => {
    // BUG: query is read here but missing from the deps below.
    // This fetches the very first query forever, silently.
    fetchResults(query).then(setResults);
  }, []);

  return <ResultList items={results} />;
}

No error. No warning at runtime. The search box just stops working after the first keystroke, and you spend twenty minutes checking the network tab before you look at the array. Turn on the react-hooks/exhaustive-deps ESLint rule and this class of bug disappears from your life.

Objects and functions restart the effect every render

Dependencies are compared with Object.is, which for objects, arrays and functions means reference identity, not contents. An object literal declared in the component body is a brand new reference on every render, so an effect depending on it runs on every render.

tsx
function Chat({ roomId }: { roomId: string }) {
  // New object every render.
  const options = { serverUrl: 'https://chat.example.com', roomId };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // Reconnects on every render. The chat never stabilises.
}

The fix people reach for is useMemo around options. That works, but the better fix is usually to move the object inside the effect so it stops being a dependency at all:

tsx
useEffect(() => {
  // roomId is a string, so the comparison is by value and behaves.
  const connection = createConnection({
    serverUrl: 'https://chat.example.com',
    roomId,
  });
  connection.connect();
  return () => connection.disconnect();
}, [roomId]);

Prefer primitives in the dependency array. When you genuinely cannot, useMemo and useCallback exist to stabilise those references, and that post covers when the cost of memoising is worth paying.

The useEffect infinite loop

Setting state inside an effect that depends on that same state is the classic infinite render loop. React runs the effect, the effect sets state, the state change triggers a render, the render triggers the effect.

tsx
const [items, setItems] = useState<Item[]>([]);

useEffect(() => {
  setItems([...items, newItem]); // sets items
}, [items]);                     // depends on items

Cleanup functions

Whatever you return from an effect, React calls before the next run of that effect and once more when the component unmounts. Not a lifecycle curiosity: it is the half of the effect that stops your app leaking.

Skipping cleanup on a listener or a timer means every remount stacks another one. A component mounted and unmounted twenty times during a session has twenty intervals firing. The symptom is a page that gets progressively slower the longer someone uses it, which is a hard bug to reproduce on a fresh reload.

tsx
useEffect(() => {
  const id = setInterval(() => {
    setSeconds((s) => s + 1);
  }, 1000);

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

The rule is mechanical. If the effect body calls addEventListener, setInterval, setTimeout, subscribe, observe or opens a connection, the cleanup calls the matching teardown. If it does none of those, you probably do not need cleanup, and you may not need the effect either.

Why your effect runs twice in development

Since React 18, Strict Mode mounts every component, runs its effects, runs the cleanup, and mounts again. In development only. This is deliberate and it is not a bug in your code, though it is very good at revealing one.

React is checking that your effect is resilient to being run twice, because that is exactly what happens in production when a component remounts, and increasingly what happens with Fast Refresh and future features that reuse component state. An effect that breaks under the double invocation is an effect that was already broken, just not yet visibly.

Two duplicate fetches in the network tab are usually harmless in development. Two analytics events are not, and neither is anything that writes. For those, the honest fix is to make the operation safe to repeat or move it out of an effect entirely.

Async effects and the race condition

The effect callback cannot be async. React expects it to return either nothing or a cleanup function, and an async function returns a Promise, which React will happily try to call as cleanup.

tsx
// Does not work. Returns a Promise where React wants a cleanup function.
useEffect(async () => {
  const data = await fetchUser(userId);
  setUser(data);
}, [userId]);

Declare the async function inside and call it. That is the mechanical part. The interesting part is what happens when userId changes faster than the network responds.

tsx
useEffect(() => {
  let cancelled = false;

  async function load() {
    const data = await fetchUser(userId);
    // The effect for a newer userId has already run. Drop this response.
    if (!cancelled) setUser(data);
  }

  load();
  return () => {
    cancelled = true;
  };
}, [userId]);

Without that flag, a slow response for user 1 can land after a fast response for user 2 and overwrite it. The profile shows the wrong person. It happens on flaky connections and almost never on your machine, which is why it survives review so often. I went through this pattern and the AbortController version of it in more depth in fetching data in React.

When not to use useEffect

This is where the real wins are. A large share of the effects in an average codebase should not exist.

Deriving state from props or other state

Storing a value in state and syncing it with an effect gives you two renders and a moment where the two are inconsistent. Compute it during render instead.

tsx
// Unnecessary. Two renders, and fullName is briefly stale.
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);

// Just calculate it.
const fullName = firstName + ' ' + lastName;

Only reach for useMemo here if the calculation is genuinely expensive. String concatenation is not.

Responding to a user event

If something should happen because a user clicked a button, the logic goes in the click handler. Routing it through state and an effect makes it fire in situations you did not intend, such as a remount or a navigation back to the page.

Fetching data in a real application

Fetching in an effect is correct in the sense that it works. It also leaves caching, deduplication, retries, focus revalidation and the race condition above as your problem. Past a handful of requests, a library earns its bundle size several times over. If you are choosing one, I compared the options in fetching data in React with SWR and TanStack Query.

useEffect versus useLayoutEffect

Both run after render. The difference is when relative to paint. useEffect runs after the browser has painted, so it never blocks the screen updating. useLayoutEffect runs before paint, synchronously, so the user never sees the intermediate state.

That matters in one situation: you need to measure the DOM and change it before anyone sees it. Positioning a tooltip against a trigger is the canonical case. Do it in useEffect and the tooltip renders at the wrong position for one frame, then jumps. The flicker is real and users notice it.

tsx
const ref = useRef<HTMLDivElement>(null);
const [top, setTop] = useState(0);

// Runs before paint, so no visible jump.
useLayoutEffect(() => {
  const rect = ref.current?.getBoundingClientRect();
  if (rect) setTop(rect.bottom + 8);
}, []);

Because it blocks painting, keep it small, and note that it does not run during server rendering. React will warn you if you use it in a component that renders on the server. For everything that is not measure-then-mutate, use useEffect. The ref in that example is doing real work too, and refs are worth understanding on their own terms: I cover them in useRef in React.

What I actually check when an effect misbehaves

In order, because this catches nearly everything:

SymptomFirst thing to look at
Runs too oftenAn object, array or function in the dependency array getting a new reference each render
Runs once and then never againAn empty array that should list a value the effect body reads
Maximum update depth exceededThe effect writes to a value it also depends on
Runs twice on mountStrict Mode in development. Add cleanup rather than removing Strict Mode
Stale value inside the callbackA missing dependency, or a closure captured by a timer that was never cleaned up
Visible flicker after renderMeasurement work that belongs in useLayoutEffect

The habit worth building is not memorising that table. It is asking, each time you type useEffect, which external system this is synchronising with. If you cannot name one, the code probably belongs in the render body, in an event handler, or in a data fetching library. The effects that remain after that question are usually few, small, and boring, which is exactly what you want from them.

Frequently asked questions

Why does my useEffect run twice on mount?

React Strict Mode mounts each component, runs its effects, runs the cleanup, then mounts again. This happens in development only, never in production. It is a deliberate check that your effect can be run twice safely, which is what happens whenever a component remounts. The fix is to add a cleanup function, not to remove Strict Mode.

How do I use async await inside useEffect?

You cannot make the effect callback itself async, because React expects it to return either nothing or a cleanup function, and an async function returns a Promise. Declare an async function inside the effect and call it. Add a cancelled flag that the cleanup sets to true, and check it before calling setState, otherwise a slow response can overwrite a newer one.

What happens if I leave out the useEffect dependency array?

Omitting the second argument entirely makes the effect run after every render, which is almost never what you want. Passing an empty array runs it once after the first render. Leaving a value out of a non-empty array does not stop the effect reacting to it, it makes the effect run with a stale closure over the old value, which produces bugs that only appear on the second interaction.

What causes Maximum update depth exceeded in useEffect?

The effect sets a piece of state that it also lists as a dependency, so setting it triggers a render, which triggers the effect again. It happens most often with object or array dependencies, because the effect replaces them with a fresh reference each run even when the contents are identical.

Related Articles