Skip to content

Advanced React Behavior and Tests

Step 26 of 45

Effects, API Calls, 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.

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 render
useEffect(fn, []) After the first render only (and cleanup on unmount)
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. Lying to the linter by omitting dependencies is the most frequent cause of stale closures: the effect sees old values from the render in which it was created.

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

Fix: [term].

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.

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

Solutions, in order of preference: depend on the primitive ([category]), create the object inside the effect, or move a constant outside the component. 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 needed for the exercises of this lesson, but you should know it exists so you do not remove dependencies from the array to achieve the same result.

3. Fetching data

The classic pattern uses three pieces of state.

function WorkshopList({ category }) {
  const [workshops, setWorkshops] = useState([]);
  const [status, setStatus] = useState('loading'); // 'loading' | 'error' | 'success'

  useEffect(() => {
    const controller = new AbortController();
    setStatus('loading');

    fetch(`/api/workshops?category=${encodeURIComponent(category)}`, {
      signal: controller.signal,
    })
      .then(response => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.json();
      })
      .then(data => {
        setWorkshops(data);
        setStatus('success');
      })
      .catch(error => {
        if (error.name === 'AbortError') return; // expected during cleanup
        setStatus('error');
      });

    return () => controller.abort();
  }, [category]);

  if (status === 'loading') return <p role="status">Loading workshops...</p>;
  if (status === 'error') return <p role="alert">Could not load workshops. Try again.</p>;
  if (workshops.length === 0) return <p>No workshops in this category.</p>;

  return (
    <ul>
      {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.
useEffect(() => {
  async function load() {
    const response = await fetch(url);
    ...
  }
  load();
}, [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. Aborting the previous request in cleanup ensures only the latest one can update state. When you cannot abort (for example a third-party library), use an ignore flag:

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.

function WindowWidth() {
  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}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:

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

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

  return <p>Your seat is held for {seconds}s</p>;
}

Each render schedules one timeout; cleanup cancels it if the component re-renders or unmounts first.

Strict Mode in development

In development, <StrictMode> mounts each component, runs its effects, runs the cleanups, then runs the effects again. 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.

useEffect(() => {
  if (term.trim() === '') {
    setResults([]);
    return;
  }
  const controller = new AbortController();
  fetch(`/api/workshops?q=${encodeURIComponent(term)}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setResults)
    .catch(err => { if (err.name !== 'AbortError') setError(true); });
  return () => controller.abort();
}, [term]);

Step 3: reason. When term changes from "pot" to "potte", React runs the cleanup of the previous effect, which aborts the "pot" request; its catch receives an AbortError, which is ignored. Only the request for "potte" can call setResults.

Step 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 is the right way to learn the mechanics, and the Meta course exercises expect it. 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. Move the pattern into a custom hook (useWorkshops(category)) as soon as two components need it; the next lesson shows how.
  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. A shared hook with a cache, or a data library, sends one. 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.

Exam tips

  • "Runs once after mount" is []; "runs after every render" is no array at all; know both.
  • 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, the counter decrements by three seconds per second and the browser tab slows down.

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

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

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

Analysis. Each mount creates a new interval and never clears it. The router unmounts the dashboard on the Contact page, but the old interval keeps running and calls setSeconds on an unmounted instance. Returning to the dashboard creates another interval, so after two round trips there are three timers running. Strict Mode in development would have shown the problem immediately by running setup twice (two decrements per second).

Solution.

useEffect(() => {
  const id = setInterval(() => setSeconds(s => 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