Skip to content

Advanced React: Hooks, Composition, Testing, and Performance

Step 26 of 45

React useEffect: API Calls, Dependencies, and Cleanup

Introduction

This is lesson 2 of chapter 6, aligned with course 6 of the Meta Front-End Developer pathway ("Advanced React", domain advanced-react). Rendering must be a pure calculation: given props and state, a component returns JSX. But real applications must also talk to the outside world: load data from an API, subscribe to a browser event, start a timer, or update the document title. These actions are side effects, and React gives them a dedicated place: the useEffect hook.

This lesson explains what an effect is, when you need one (and when you do not), how the dependency array works, how to fetch data safely with loading and error states, and why cleanup functions prevent leaks and race conditions. The scenario is original: the Harbor Workshops page must load the list of workshops from a JSON endpoint, filter them by a search term, show the number of seats in the document title, and listen for window resizing.

Learning objectives

  1. Explain the difference between rendering and an effect.
  2. Write useEffect with a correct dependency array and predict when it runs.
  3. Decide whether logic belongs in an event handler, in render, or in an effect.
  4. Fetch data in an effect with loading, error, and data states.
  5. Return a cleanup function for timers, listeners, and subscriptions.
  6. Prevent stale responses and race conditions with AbortController or an ignore flag.
  7. Recognise Strict Mode's double-invocation in development and why it is useful.
  8. Identify "you might not need an effect" situations.

1. Rendering versus effects

During rendering React calls your component function. That function must be pure: the same props, state, and context must return the same JSX, with no observable changes outside the component. Calling fetch, changing document.title, or starting a setInterval inside the body of the component would repeat the action on every render, and React may render a component more than once.

Effects run after React has committed the result to the DOM. This is the safe moment to synchronise the component with something outside React.

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

function SeatCounter({ seats }) {
  useEffect(() => {
    document.title = `${seats} seats left | Harbor Workshops`;
  }, [seats]);

  return <p>{seats} seats left</p>;
}

The effect function runs after the first render and again after any render where seats changed.

Three places for logic

Where Use it for Example
Render (component body) Calculations from props and state Filtering a list, formatting a price
Event handler Reactions to a user action Submitting a booking, clicking Reserve
Effect Synchronising with an external system because the component is displayed Loading data on mount, subscribing to resize, updating the title

A common beginner mistake is placing event logic in an effect ("when submitted becomes true, send the request"). If the cause is a click, put the request in the click handler.

2. The dependency array

useEffect(setup, dependencies) accepts an optional second argument that controls when the effect re-runs.

Form Runs
useEffect(fn) After every committed render of this component
useEffect(fn, []) On mount, with cleanup on unmount; development Strict Mode can add a setup/cleanup cycle
useEffect(fn, [a, b]) After the first render and after any render where a or b changed (compared with Object.is)

The rule

Every reactive value used inside the effect (props, state, and variables or functions derived from them) must appear in the array. The ESLint plugin react-hooks/exhaustive-deps checks this. Omitting reactive dependencies can produce stale closures: the effect sees old values from the render in which it was created.

JSX
function SearchLogger({ term }) {
  useEffect(() => {
    console.log('Searching for', term);
  }, []); // BUG: logs only the first term, never updates
  return null;
}

Use [term] and review React state snapshots and rendering to understand which values the closure captures.

Objects and functions as dependencies

Objects and functions created inside the component are new on every render, so using them as dependencies makes the effect run each time.

JSX
function List({ category }) {
  const options = { category };               // new object each render
  useEffect(() => { load(options); }, [options]); // runs every render
}

Move the object creation into the effect and depend on category, or move a genuine constant outside the component. Changing [options] to [category] alone while still reading a render-created options object is not a sound dependency fix. Use useMemo or useCallback only when the value really must be shared and stable.

Newer: Effect Events

React 19.2 added useEffectEvent for the case where an effect must read the latest value of something (for example a theme used in a notification) without re-running when that value changes. It is not a way to hide dependencies. Call Effect Events only from effect-related logic in the same component; do not pass them as UI event handlers or include them as dependencies. Consult the current API and lint rules.

3. Fetching data

The exercise endpoint must return a JSON array of records with unique string IDs and string titles. A Vite project does not create this API automatically. Centralize the response checks in a small module:

JavaScript
// src/workshopApi.js
export async function fetchWorkshopList(url, { signal } = {}) {
  const response = await fetch(url, { signal });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const data = await response.json();
  if (!Array.isArray(data) || data.some(item =>
    !item || typeof item.id !== 'string' || typeof item.title !== 'string'
  ) || new Set(data.map(item => item.id)).size !== data.length) {
    throw new Error('Invalid workshop response');
  }
  return data;
}

Keep the request key with its result so a changed category cannot briefly display the previous category's records before its effect runs.

JSX
// src/WorkshopList.jsx
import { useState, useEffect } from 'react';
import { fetchWorkshopList } from './workshopApi.js';

export function WorkshopList({ category }) {
  const [result, setResult] = useState({ category: null, status: 'loading', workshops: [] });
  useEffect(() => {
    let active = true;
    const controller = new AbortController();
    setResult({ category, status: 'loading', workshops: [] });
    fetchWorkshopList(`/api/workshops?category=${encodeURIComponent(category)}`, {
      signal: controller.signal,
    }).then(workshops => {
      if (active) setResult({ category, status: 'success', workshops });
    }).catch(error => {
      if (active && error.name !== 'AbortError') {
        setResult({ category, status: 'error', workshops: [] });
      }
    });
    return () => { active = false; controller.abort(); };
  }, [category]);

  if (result.category !== category || result.status === 'loading') return <p role="status">Loading workshops...</p>;
  if (result.status === 'error') return <p role="alert">Could not load workshops. Try again.</p>;
  if (result.workshops.length === 0) return <p>No workshops in this category.</p>;
  return <ul>{result.workshops.map(w => <li key={w.id}>{w.title}</li>)}</ul>;
}

Important points:

  • fetch does not reject on HTTP errors. A 404 or 500 resolves normally; you must test response.ok.
  • Handle all states. Loading, error, empty, and success are four distinct displays.
  • async cannot be the effect function itself because an async function returns a promise, while an effect may only return a cleanup function or nothing. Define an inner async function and call it, or use promise chains.
JSX
// Fragment inside a component with url and state setters.
useEffect(() => {
  let ignore = false;
  async function load() {
    try {
      const data = await fetchWorkshopList(url);
      if (!ignore) setData(data);
    } catch (error) {
      if (!ignore) setError(error);
    }
  }
  load();
  return () => { ignore = true; };
}, [url]);

Cleanup and race conditions

Imagine the user switches the category from "Cooking" to "Pottery" quickly. Two requests are in flight. If the "Cooking" response arrives after the "Pottery" response, the list will show cooking workshops under a pottery heading. This is a race condition.

The cleanup function runs before the effect re-runs and when the component unmounts. Abort requests to avoid unnecessary work, and use an active/ignore guard so already-completed or non-abortable continuations cannot update the obsolete effect. Abort alone cannot undo code that already ran, or roll back a server-side operation. When you cannot abort (for example a third-party library), use an ignore flag:

JSX
useEffect(() => {
  let ignore = false;
  getWorkshops(category).then(data => {
    if (!ignore) setWorkshops(data);
  });
  return () => { ignore = true; };
}, [category]);

4. Subscriptions, timers, and cleanup

Anything you start in an effect, you must stop in its cleanup.

JSX
function WindowWidth() {
  const [width, setWidth] = useState(null);

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

  return <p>Window: {width === null ? '…' : `${width}px`}</p>;
}

Without the cleanup, each mount adds another listener that is never removed. After navigating between pages several times, dozens of listeners would update state of components that no longer exist.

A timer example, a countdown for a seat hold:

JSX
function HoldTimer({ initialSeconds }) {
  const [seconds, setSeconds] = useState(initialSeconds);

  useEffect(() => {
    if (seconds <= 0) return;
    const id = setTimeout(() => setSeconds(s => Math.max(0, s - 1)), 1000);
    return () => clearTimeout(id);
  }, [seconds]);

  return <p>Demo countdown: {seconds}s</p>;
}

Each change to seconds schedules the next timeout; unrelated renders do not restart it. Cleanup cancels it before the next run or unmount. This local countdown can drift when the browser throttles timers; it neither creates nor verifies a real seat hold.

Strict Mode in development

With root Strict Mode in development, React adds a setup/cleanup/setup check for effects. This is not an ordinary full remount that resets all component state; subtree placement can affect which checks run. This deliberately exposes missing cleanup: if your effect works after "setup, cleanup, setup", it will work when a user navigates away and back. Do not try to disable the double run; fix the effect. This does not happen in production builds.

5. You might not need an effect

Many effects are unnecessary and cause extra renders.

Tempting effect Better approach
Compute filtered from items and term and store it with setFiltered Calculate it during render: const filtered = items.filter(...)
Reset a form when a prop changes Give the component a key so React recreates it
Send a request after a click Do it in the click handler
Copy a prop into state on every change Use the prop directly, or lift the state
Chain effects that set state to trigger other effects Compute everything in one handler or render

Rule of thumb: if no external system is involved, you probably do not need an effect.

Worked example: filtered workshops with a stale response bug

Statement. A page has a text input term. On each change it fetches /api/workshops?q=term. Testers report that searching "pot" and then "potte" sometimes shows the results for "pot". Fix it and explain.

Step 1: identify the failure. Each keystroke starts a request. Network latency is not ordered: the response for "pot" can arrive after the response for "potte". The last response to arrive wins, which may be the wrong one.

Step 2: apply cleanup.

JSX
// Uses the same fetchWorkshopList helper and React imports as above.
function SearchResults({ term }) {
  const query = term.trim();
  const [result, setResult] = useState({ query: null, status: 'idle', workshops: [] });
  useEffect(() => {
    if (!query) {
      setResult({ query, status: 'idle', workshops: [] });
      return;
    }
    let active = true;
    const controller = new AbortController();
    setResult({ query, status: 'loading', workshops: [] });
    fetchWorkshopList(`/api/workshops?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    }).then(workshops => {
      if (active) setResult({ query, status: 'success', workshops });
    }).catch(error => {
      if (active && error.name !== 'AbortError') setResult({ query, status: 'error', workshops: [] });
    });
    return () => { active = false; controller.abort(); };
  }, [query]);

  if (!query) return <p>Enter a workshop title.</p>;
  if (result.query !== query || result.status === 'loading') return <p role="status">Searching...</p>;
  if (result.status === 'error') return <p role="alert">Search failed. Try again.</p>;
  if (result.workshops.length === 0) return <p>No workshops match.</p>;
  return <ul>{result.workshops.map(w => <li key={w.id}>{w.title}</li>)}</ul>;
}

Step 3: reason. Changing the query cleans up the old effect: its active flag becomes false and its fetch is aborted. An obsolete continuation cannot publish results even if cancellation came too late. HTTP and response-shape errors are handled, prior errors are cleared for a new query, and an empty query shows its own state.\n\nStep 4: why other options are worse. Removing term from the dependency array would freeze the search on the first term. Adding a setTimeout delay (debounce) alone reduces requests but does not remove the race. Disabling Strict Mode hides the symptom in development but not in production.

Bonus. To also reduce traffic, debounce the input by storing the typed text in one state and the debounced term in another, updated by a timer effect with cleanup.

6. Beyond hand-written fetch effects

Writing a fetch effect by hand helps teach the mechanics covered by the course's fetching and Hooks material. In production applications, however, teams often delegate data loading to a dedicated library (such as TanStack Query or SWR) or to the data features of a framework. These tools add caching, request de-duplication, retries, and background refresh, which a hand-written effect does not provide. The underlying ideas are identical: a request is a side effect, it depends on parameters such as category, and an outdated response must never overwrite a newer one.

Practical guidance for a small project:

  1. Start with the effect pattern from this lesson so you understand loading, error, and cleanup.
  2. Extract custom data-loading hooks when the same logic is reused, while remembering that extraction alone does not share a cache.
  3. Adopt a library only when you need shared caching or refresh behaviour, not just to avoid typing a few lines.

For example, if the workshop list appears in both the catalog and the booking summary, two independent effects would send two identical requests. Extracting a hook alone still makes independent requests per caller. A cache shared between callers, a suitable data library, or one lifted owner can deduplicate them, depending on configuration. Recognising this cost is more important than memorising a particular library.

Common pitfalls

  • Missing cleanup for listeners, timers, and subscriptions.
  • Omitting dependencies to silence the linter, producing stale values.
  • Updating state in an effect without a condition, triggering an infinite render loop (for example useEffect(() => setCount(count + 1)) with no array).
  • Treating fetch as failing on HTTP 404/500. Check response.ok.
  • Making the effect callback async.
  • Using an effect to derive state that could be computed in render.
  • Putting an object or function recreated each render in the dependency array.
  • Forgetting loading and error states, leaving the user with a blank page.
  • Calling setState after unmount without aborting or ignoring.

Study tips

  • An empty array describes mount/unmount synchronization, subject to the extra development check; no array re-runs after every commit.
  • A question mentioning "memory leak" or "keeps firing after leaving the page" points to a missing cleanup.
  • A question showing results from an old search appearing means a race condition: cleanup with abort or an ignore flag.
  • The cleanup runs before the next effect run and on unmount, not only on unmount.
  • When asked where to run code triggered by a click, choose the event handler.

Mini case study: the dashboard that kept ticking

Situation. A seat dashboard shows the time remaining for a hold. Users report that after visiting the Contact page and coming back, background callbacks keep firing after leaving the page, and the browser tab slows down.

JSX
function HoldDashboard() {
  const [seconds, setSeconds] = useState(600);

  useEffect(() => {
    setInterval(() => setSeconds(s => Math.max(0, s - 1)), 1000);
  }, []);

  return <p>{seconds}s left</p>;
}

Analysis. Each mount leaks an interval, but an interval closing over an unmounted component does not decrement a newly mounted instance. The code explains retained callbacks and wasted work; it does not explain a three-times-faster new counter after two visits. Root Strict Mode can expose missing cleanup with two active intervals for the current development instance. Diagnose a faster production counter by inspecting whether state is actually shared or the component remains mounted.\n\nSolution.

JSX
useEffect(() => {
  const id = setInterval(() => setSeconds(s => Math.max(0, s - 1)), 1000);
  return () => clearInterval(id);
}, []);

Using the functional update s => s - 1 avoids a dependency on seconds.

Why other fixes are worse. Clearing the interval inside the callback only works when the value reaches zero. Moving the interval to the component body would create a new timer on every render.

Key terms

  • Side effect: an action that affects something outside the component's pure calculation.
  • Effect: code registered with useEffect that runs after rendering.
  • Dependency array: the list of reactive values that decides when an effect re-runs.
  • Cleanup function: the function returned by an effect, run before the next run or on unmount.
  • Stale closure: a function that keeps reading old values from an earlier render.
  • Race condition: a bug where the result depends on the unpredictable order of asynchronous events.
  • AbortController: a browser API that cancels fetch requests.
  • Strict Mode: a development tool that double-invokes effects to reveal impurity.
  • Debounce: delaying an action until input has stopped changing for a short time.
  • Effect Event: a function from useEffectEvent that reads the latest values without being a dependency.

Summary checklist

  • I keep rendering pure and put external synchronisation in effects.
  • I place user-triggered logic in event handlers, not effects.
  • I list every reactive value used in an effect in its dependency array.
  • I fetch with loading, error, empty, and success states and check response.ok.
  • I write cleanup for listeners, timers, and requests.
  • I prevent race conditions with abort or an ignore flag.
  • I understand why Strict Mode runs effects twice in development.
  • I derive values during render instead of syncing state with effects.

Official sources