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
- Explain the difference between rendering and an effect.
- Write
useEffectwith a correct dependency array and predict when it runs. - Decide whether logic belongs in an event handler, in render, or in an effect.
- Fetch data in an effect with
loading,error, anddatastates. - Return a cleanup function for timers, listeners, and subscriptions.
- Prevent stale responses and race conditions with
AbortControlleror an ignore flag. - Recognise Strict Mode's double-invocation in development and why it is useful.
- 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 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.
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.
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:
// 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.
// 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:
fetchdoes not reject on HTTP errors. A 404 or 500 resolves normally; you must testresponse.ok.- Handle all states. Loading, error, empty, and success are four distinct displays.
asynccannot 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.
// 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:
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(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:
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.
// 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:
- Start with the effect pattern from this lesson so you understand loading, error, and cleanup.
- Extract custom data-loading hooks when the same logic is reused, while remembering that extraction alone does not share a cache.
- 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
fetchas failing on HTTP 404/500. Checkresponse.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
setStateafter 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.
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.
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
useEffectthat 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
fetchrequests. - 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
useEffectEventthat 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
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 6: Advanced React (opens in a new tab)
- React: Synchronizing with Effects (opens in a new tab)
- React: You Might Not Need an Effect (opens in a new tab)
- React: Lifecycle of Reactive Effects (opens in a new tab)
- React:
useEffectreference (opens in a new tab) - React 19.2 release (opens in a new tab)
- MDN: AbortController (opens in a new tab)
- React: useEffectEvent limitations (opens in a new tab)
