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
- 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 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:
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.
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:
- Start with the effect pattern from this lesson so you understand loading, error, and cleanup.
- Move the pattern into a custom hook (
useWorkshops(category)) as soon as two components need it; the next lesson shows how. - 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
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.
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
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)
