Lesson 11 of 20

useEffect Hook

What an Effect Is For

Everything so far has stayed inside React's world: data goes in, UI comes out. But applications have to touch things React knows nothing about — a server, the browser's document title, localStorage, a timer, a WebSocket, a map or chart library that draws into a DOM node. Those interactions are called side effects, and they cannot happen during render, because render must be pure.

useEffect is the escape hatch. You give it a function, and React runs that function after it has rendered your component and updated the screen. By then the DOM exists, the user can see the result, and doing something to the outside world is safe.

The most useful way to think about an effect is synchronisation rather than do this when that happens. An effect's job is to keep some external thing in step with your component's current props and state, and to undo that connection when the component goes away or the values change. Reading it that way — this effect keeps the document title matching the unread count — makes the dependency array and the cleanup function obvious instead of arbitrary.

Effects are also the most misused hook in React. A great deal of code that lives in an effect should be a calculation during render or a line in an event handler. The last section of this lesson is about recognising those cases, because removing an unnecessary effect fixes more bugs than any amount of careful dependency management.

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

function InboxTitle({ unread }) {
  // Keep something OUTSIDE React in step with something inside it.
  useEffect(() => {
    document.title = unread > 0 ? `(${unread}) Inbox` : 'Inbox';
  }, [unread]);

  return <h1>Inbox</h1>;
}

// The order of events on every render:
//   1. React runs your component function
//   2. React updates the DOM
//   3. The browser paints the screen
//   4. React runs your effects
Notes
  • There is a sibling hook, useLayoutEffect, that runs before the browser paints rather than after. It exists for the narrow case of measuring an element and adjusting it before the user can see the intermediate state — a tooltip that must flip when it would fall off screen, for instance. It blocks painting, so use useEffect unless you have that specific problem.

The Dependency Array

The second argument to useEffect decides when the effect runs again. React remembers the array from the previous render and compares each element with the new one, by identity — the same check === performs. If every element matches, React skips the effect. If any differ, it runs it again.

There are three forms and they are worth memorising. No array at all means the effect runs after every single render, which is almost never what you want and is the fastest route to an infinite loop. An empty array, [], has nothing that can ever change, so the effect runs once when the component mounts. An array with values runs the effect on mount and again whenever one of those values changes.

The rule for filling it in is mechanical: every prop, state variable or other value from inside the component that the effect reads must be listed. Not values that only exist inside the effect, and not setter functions from useState, which React guarantees are stable. Install the eslint-plugin-react-hooks lint rule and it will tell you what is missing — every React project should have it switched on.

The temptation, when the rule complains, is to delete the dependency to make the warning go away. Do not. The array is not a control panel for when the effect fires; it is a description of what the effect depends on. Removing an entry does not make the effect stop needing that value — it just guarantees the effect will run with a stale copy of it.

Example
// Every render — rarely correct
useEffect(() => {
  console.log('after every render');
});

// Once, when the component mounts
useEffect(() => {
  document.title = 'Dashboard';
}, []);

// On mount, and again whenever userId changes
useEffect(() => {
  loadProfile(userId);
}, [userId]);

// Several dependencies: any one changing re-runs the effect
useEffect(() => {
  saveDraft({ title, body });
}, [title, body]);
  • No array — after every render
  • [] — once, on mount
  • [a, b] — on mount, and whenever a or b changes
  • List every prop and state value the effect reads
  • Setter functions from useState are stable and need not be listed
  • Never remove a dependency to silence the lint rule — fix the effect instead
Notes
  • Dependencies are compared by identity, not by contents. Two arrays with the same items are still two different arrays, so [1, 2] from this render never matches [1, 2] from the last one. That single fact explains the infinite loop in the next section.

Cleanup: Undoing What the Effect Did

If an effect starts something ongoing — a timer, an event listener, a subscription, a request — it must also know how to stop it. Return a function from your effect and React will call it at the right moments: before running the effect again, and once more when the component is removed from the screen.

Skip the cleanup and you get leaks that are invisible until they are not. A component that adds a resize listener on mount and is rendered a hundred times over a session leaves a hundred listeners attached, all still firing and all still holding references to state that no longer matters. An interval that is never cleared keeps running after the component is gone and tries to set state on something that no longer exists.

The cleanup runs before every re-run, not only on unmount, and that ordering is what makes effects reliable. When roomId changes from A to B, React disconnects from room A first and then connects to room B. You never end up connected to both, and you never have to write that logic yourself.

This is also why, in development, React deliberately runs your effect an extra time — setup, cleanup, setup — when StrictMode is on. It is a test: if your effect survives that unharmed, its cleanup is correct. If a component connects twice or the console logs twice, the correct response is usually to write the missing cleanup rather than to remove StrictMode. This extra run does not happen in a production build.

Example
// Timer
useEffect(() => {
  const id = setInterval(() => setSeconds(s => s + 1), 1000);
  return () => clearInterval(id);
}, []);

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

// A subscription that has to follow a changing prop
useEffect(() => {
  const connection = chat.connect(roomId);
  return () => connection.disconnect();
}, [roomId]);

// Changing rooms runs:  disconnect(old)  ->  connect(new)
// Unmounting runs:      disconnect(current)
Notes
  • The cleanup function must undo exactly what the setup did, and it must remove the same listener it added. Passing a different function to removeEventListener than the one you passed to addEventListener silently removes nothing — which is why the handler is declared inside the effect and used in both places.

The Infinite Loop, and Why It Happens

Sooner or later an effect will freeze your browser tab. There are two causes and both are worth being able to spot in seconds.

The first is setting state in an effect with no dependency array. The effect runs after render, the setter triggers a render, the render runs the effect, and round it goes. The fix is usually to give the effect the dependencies it should have had — or to realise that the value being set is derived data that should be calculated during render instead.

The second is subtler and far more common in real code: a dependency that is a new object, array or function on every render. Remember that dependencies are compared by identity. An object literal written inside the component body — const options = { limit: 10 } — is a brand-new object each time the component runs, so React always sees a changed dependency, always re-runs the effect, and if the effect sets state, the loop is closed. The values inside are identical; the identity is not.

There are three ways out, and which one is right depends on the situation. Move the object outside the component if it never changes. Depend on the primitive values inside it rather than the object itself, since numbers and strings compare by value. Or wrap it in useMemo — or the function in useCallback — so that its identity is preserved between renders. The performance lesson covers those two hooks properly; here they are being used for correctness, not speed.

Example
// LOOP 1: state set on every render
useEffect(() => {
  setCount(count + 1);      // render -> effect -> render -> effect -> ...
});

// LOOP 2: a dependency that is recreated every render
function ProductList({ category }) {
  const options = { category, limit: 20 };     // new object every render

  useEffect(() => {
    fetchProducts(options).then(setProducts);   // sets state -> re-render -> new object -> ...
  }, [options]);                                // never equal to last time
}

// FIX A: depend on the primitives instead
useEffect(() => {
  fetchProducts({ category, limit: 20 }).then(setProducts);
}, [category]);

// FIX B: keep the object outside if it is constant
const OPTIONS = { limit: 20 };

// FIX C: preserve its identity
const options = useMemo(() => ({ category, limit: 20 }), [category]);
useEffect(() => {
  fetchProducts(options).then(setProducts);
}, [options]);
Notes
  • The same trap applies to functions passed down as props. A parent that defines const handleLoad = () => {...} in its body passes a different function on every render, so a child listing handleLoad as a dependency re-runs its effect every time the parent renders — for any reason at all.

Stale Values and Race Conditions

An effect is a closure created during a particular render, so it sees that render's props and state — for as long as it lives. Leave a dependency out and the effect keeps running with values frozen at the moment it was created. The symptom is a component that shows correct data everywhere except inside the effect, which stubbornly uses something from several seconds ago.

The same closure behaviour causes a race condition in data fetching, and this one is genuinely important because it produces wrong data on screen rather than a crash. Suppose the effect fetches a user profile whenever userId changes. The user clicks profile 1, then quickly clicks profile 2. Two requests are now in flight. If the response for 1 arrives after the response for 2 — perfectly possible, since the network makes no promises about ordering — then the older response lands last and overwrites the newer one. The page shows profile 1's details under profile 2's URL.

The standard fix uses the cleanup function. Declare a flag in the effect, set it to false in the cleanup, and check it before calling the setter. Because cleanup runs before the effect re-runs, the older effect's flag is already false by the time its response arrives, so it quietly does nothing. A more thorough version uses AbortController to cancel the request outright, which also saves bandwidth.

Example
// The ignore-flag pattern — the standard fix for out-of-order responses
useEffect(() => {
  let ignore = false;

  setLoading(true);
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      if (!ignore) {          // only the newest effect gets to write
        setUser(data);
        setLoading(false);
      }
    });

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

// Cancelling the request as well
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);   // ignore our own cancellation
    });

  return () => controller.abort();
}, [userId]);
Notes
  • The effect callback itself cannot be async. An async function returns a Promise, and React expects either nothing or a cleanup function. Declare an async function inside the effect and call it, or use .then as above.

You Probably Do Not Need That Effect

More React bugs come from effects that should not exist than from effects written incorrectly. Before writing one, check whether it belongs to one of these categories, because each has a simpler answer that cannot go stale, cannot loop, and runs less often.

The biggest one is transforming data. If you have an effect that watches some state and sets other state — filtering a list, computing a total, formatting a name — delete it and calculate the value during render. The effect version renders twice for every change, is briefly wrong in between, and adds a second source of truth that can drift.

The second is logic that belongs in an event handler. If something should happen because the user clicked — sending an analytics event, showing a confirmation, posting a form — put it in the click handler. Putting it in an effect that watches a state flag makes it fire in situations you did not intend, such as a remount, and separates the cause from the effect for anyone reading the code.

The third is resetting state when a prop changes. Rather than an effect that clears fields whenever userId changes, give the component a key equal to userId from its parent. React then treats it as a different component and rebuilds it with fresh state — no effect, no timing gap, one line.

  • Deriving a value from state or props — calculate it during render
  • Something that should happen on a click or submit — put it in the handler
  • Resetting state when a prop changes — change the component's key instead
  • Genuinely needed: fetching data, subscriptions, timers, event listeners on window
  • Genuinely needed: localStorage, the document title, and non-React libraries that draw into the DOM
Example
// UNNECESSARY: an effect that derives state from state
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);

useEffect(() => {
  setTotal(items.reduce((s, i) => s + i.price, 0));
}, [items]);

// BETTER: calculate it while rendering
const total = items.reduce((s, i) => s + i.price, 0);

// UNNECESSARY: an effect watching a flag
useEffect(() => {
  if (submitted) sendAnalytics('signup');
}, [submitted]);

// BETTER: in the handler, where the cause actually is
function handleSubmit(e) {
  e.preventDefault();
  sendAnalytics('signup');
  createAccount(form);
}

// UNNECESSARY: an effect that clears the form when the record changes
// BETTER: let React rebuild the component
<EditProfileForm key={userId} userId={userId} />
Notes
  • A useful test before writing an effect: which system outside React am I synchronising with? If you cannot name one — the DOM, the network, a timer, storage, a third-party library — the code almost certainly belongs somewhere else.
Ask AI