097: useEffect + API
Learning objective
Outcomes
You will use Effects only to synchronize with external systems, declare dependencies honestly, clean up synchronization, and fetch with loading/error handling plus stale-response protection.
I can distinguish rendering, events, and Effects and explain why production apps usually prefer framework data APIs or a client cache.
Prerequisites
Complete 096 and the JavaScript async/API fundamentals from 079–083. You should know Promises, fetch, HTTP status handling, AbortController, controlled selectors, and cleanup concepts such as removing an event listener.
Retrieval practice
- Why is form submission an event rather than an Effect?
- Why should filtered tasks be calculated during render?
- What must happen to an older request when its query is no longer current?
Content to cover
effects; dependency array; cleanup concept; data fetching; loading/error state.
Terms and mental model
An Effect synchronizes a rendered component with a system outside React: a network resource, timer, event subscription, browser API, or third-party widget. It runs after commit. It is an escape hatch, not a general “after render” bucket.
- Setup: Effect body running after commit to synchronize with an external system. — Source: React: Synchronizing with effects
- Dependency: Array controlling when an effect re-runs after commits. — Source: React: useEffect reference
- Cleanup: Return function undoing the setup before the next run or unmount. — Source: React: Synchronizing with effects
- External system: Anything outside React — network, timers, subscriptions, third-party widgets. — Source: React: Synchronizing with effects
- Race condition: Stale response overwriting newer results; prevented via ignore flag/AbortController. — Source: React: You might not need an effect — fetch
- AbortController: Browser API whose signal cancels an in-flight fetch when the effect cleans up. — Source: MDN: AbortController
Ask why code runs. Because the user clicked submit? Put it in that handler. Because a value can be calculated from props/state? Calculate during render. Because the visible component must remain synchronized with a network URL or browser subscription? Use an Effect.
Effect lifecycle
useEffect(() => {
const connection = connect(roomId);
return () => connection.disconnect();
}, [roomId]);
After the first commit, setup runs. When roomId changes, cleanup runs with the old snapshot, then setup runs with the new one. On removal, cleanup runs. In Strict Mode development, React runs an extra setup → cleanup → setup cycle to expose missing cleanup. Fix synchronization so that sequence is safe; do not disable Strict Mode or use a ref to conceal it.
No dependency array runs after every commit. [] means no reactive dependencies and setup on mount (plus the development check). [a, b] reruns when either differs by Object.is. Dependencies are determined by code, not personal preference. Do not suppress the linter; restructure code to remove a dependency only when it truly is non-reactive.
Beginner complete example: fetch tasks safely
This curriculum is a Vite client, so manual Effect + fetch is demonstrated against a deterministic local fixture. A public API belongs in optional comparison work, not in the required path.
import { useEffect, useState } from 'react';
const localTasks = [
{ id: 'local-1', title: 'Trace Effect cleanup', completed: false },
{ id: 'local-2', title: 'Test an aborted request', completed: true },
];
function getLocalTasks({ signal }) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve(localTasks), 40);
signal.addEventListener('abort', () => {
clearTimeout(timer);
const error = new DOMException('Request aborted', 'AbortError');
reject(error);
}, { once: true });
});
}
export default function RemoteTasks() {
const [tasks, setTasks] = useState([]);
const [status, setStatus] = useState('loading');
const [error, setError] = useState('');
const [requestKey, setRequestKey] = useState(0);
useEffect(() => {
const controller = new AbortController();
let ignore = false;
async function loadTasks() {
setStatus('loading');
setError('');
try {
const data = await getLocalTasks({
signal: controller.signal,
});
if (!ignore) {
setTasks(data.map((item) => ({
id: item.id,
title: item.title,
completed: item.completed,
})));
setStatus('success');
}
} catch (caughtError) {
if (!ignore && caughtError.name !== 'AbortError') {
setError(caughtError.message || 'Tasks could not be loaded.');
setStatus('error');
}
}
}
loadTasks();
return () => {
ignore = true;
controller.abort();
};
}, [requestKey]);
if (status === 'loading') return <p role="status">Loading tasks…</p>;
if (status === 'error') {
return (
<div role="alert">
<p>{error}</p>
<button type="button" onClick={() => setRequestKey((key) => key + 1)}>
Retry loading tasks
</button>
</div>
);
}
if (tasks.length === 0) return <p>No remote tasks were found.</p>;
return (
<section aria-labelledby="remote-heading">
<h2 id="remote-heading">Imported tasks</h2>
<ul>{tasks.map((task) => <li key={task.id}>{task.title}</li>)}</ul>
</section>
);
}
The controller saves network/browser work where cancellation is supported. The ignore flag also prevents an obsolete continuation from setting state, including work after an awaited parsing step. Cleanup does both. fetch does not reject for HTTP 404/500, so check response.ok before reading data.
Retry itself is an event: the button changes requestKey. The Effect synchronizes displayed data with the current request key and URL. Do not put a POST that creates a task in an Effect; the submit handler should perform that user-triggered request.
Intermediate: dependency-driven project selection
import { useEffect, useState } from 'react';
function ProjectTasks({ projectId }) {
const [tasks, setTasks] = useState([]);
const [status, setStatus] = useState('loading');
const [error, setError] = useState('');
useEffect(() => {
const controller = new AbortController();
let ignore = false;
async function load() {
setStatus('loading');
setError('');
const response = await fetch(`/api/projects/${projectId}/tasks`, {
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const nextTasks = await response.json();
if (!ignore) {
setTasks(nextTasks);
setStatus('success');
}
}
load().catch((error) => {
if (!ignore && error.name !== 'AbortError') {
setError(error.message);
setStatus('error');
}
});
return () => {
ignore = true;
controller.abort();
};
}, [projectId]);
if (status === 'loading') return <p role="status">Loading project tasks...</p>;
if (status === 'error') return <p role="alert">{error}</p>;
if (tasks.length === 0) return <p>No tasks found for this project.</p>;
return (
<ul>
{tasks.map((task) => <li key={task.id}>{task.title}</li>)}
</ul>
);
}
Rapidly changing projectId starts a new synchronization. Cleanup invalidates the old one, so a slow old response cannot overwrite current project tasks. Do not make the Effect callback itself async; React expects it to return cleanup or nothing, not a Promise. Define and call an inner async function.
You might not need an Effect
These are wrong uses:
useEffect(() => setOpenCount(tasks.filter((t) => !t.completed).length), [tasks]);
useEffect(() => setVisibleTasks(filterTasks(tasks, filter)), [tasks, filter]);
useEffect(() => { if (submitted) postTask(draft); }, [submitted, draft]);
Derive count and visible tasks during render. Call postTask in submit. Unnecessary Effects create extra renders, stale frames, dependency complexity, and loops.
Subscriptions do need Effects:
useEffect(() => {
function handleOnline() { setOnline(navigator.onLine); }
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOnline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOnline);
};
}, []);
For external stores specifically, React's useSyncExternalStore is the purpose-built API, but the subscription example demonstrates mirrored cleanup.
Production data guidance
Manual fetching is educational but has limitations: no server data in initial HTML, easy network waterfalls, no caching/deduplication, repetitive race handling, and refetching after remount. In production, prefer a React framework's route/data APIs when using a framework, or a maintained client cache such as TanStack Query/SWR when appropriate. Those tools can preload, cache, deduplicate, retry, and coordinate requests. This Vite client curriculum intentionally shows the underlying Effect pattern so you understand synchronization and cleanup; it does not claim this is the best production architecture.
Optional advanced: extracting synchronization
Repeated fetching can be hidden behind a custom Hook with a declarative API, but a small Hook is still not a cache. It must retain dependency, cleanup, error, and race guarantees. Avoid wrapping every Effect merely to move lines. Extract when several components need one well-defined behavior and test it.
Do not solve changing object dependencies by adding useMemo automatically. Often create request options inside the Effect and depend on primitive inputs. Module constants such as API_URL are not reactive and need not be dependencies.
Mistakes and debugging
- Effect as event handler causes actions on remount or Back navigation.
- Missing cleanup allows stale responses or duplicate subscriptions.
- Suppressed dependency warning creates stale closures.
asyncEffect callback returns a Promise instead of cleanup.- Treating HTTP 500 as success because
response.okwas not checked. - Showing abort as an error confuses normal cleanup with failure.
- Infinite loop: Effect sets state that changes its own dependency every run.
- Disabling Strict Mode hides rather than fixes cleanup defects.
- Adding
useMemo/useCallbackto appease dependencies instead of simplifying setup.
Use Network panel to inspect status, timing, and aborted requests. Throttle network and switch IDs quickly to test races. Log setup and cleanup together. Verify that setup → cleanup → setup leaves one active resource. Simulate non-OK responses and offline mode, not only success.
Accessibility and performance
Use concise loading status, useful error text, and a keyboard-operable retry button. Avoid indefinite spinners without text. Keep stable page headings to preserve orientation. If refreshing existing data, consider showing old content with a separate “Updating” status rather than replacing everything and moving focus.
Abort obsolete requests and avoid waterfalls. Production caches and framework APIs usually outperform ad hoc Effects. Do not memoize response mapping without measurement. Keep fetched raw/normalized data minimal and derive view filters during render.
Practice
Fetch API data into a React screen.
Tiered exercises
Core: Fetch tasks, check response.ok, and display loading/error/empty/success.
Stretch: Add retry and cleanup using AbortController plus an ignore flag.
Challenge: Add a controlled user/project selector, race requests under throttling, and prove stale responses cannot win. Explain the production alternative.
For dependency-driven selection, use the same cleanup and status pattern with a userId prop:
import { useEffect, useState } from 'react';
function RemoteTasks({ userId }) {
const [tasks, setTasks] = useState([]);
const [status, setStatus] = useState('loading');
const [error, setError] = useState('');
const [requestKey, setRequestKey] = useState(0);
useEffect(() => {
const controller = new AbortController();
let ignore = false;
async function loadTasks() {
setStatus('loading');
setError('');
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos?userId=${userId}`,
{ signal: controller.signal },
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (!ignore) {
setTasks(data.map((item) => ({
id: item.id,
title: item.title,
completed: item.completed,
})));
setStatus('success');
}
} catch (caughtError) {
if (!ignore && caughtError.name !== 'AbortError') {
setError(caughtError.message || 'Tasks could not be loaded.');
setStatus('error');
}
}
}
loadTasks();
return () => {
ignore = true;
controller.abort();
};
}, [userId, requestKey]);
if (status === 'loading') return <p role="status">Loading tasks...</p>;
if (status === 'error') {
return (
<div role="alert">
<p>{error}</p>
<button type="button" onClick={() => setRequestKey((key) => key + 1)}>
Retry loading tasks
</button>
</div>
);
}
if (tasks.length === 0) return <p>No tasks were found for this user.</p>;
return (
<section aria-labelledby="remote-heading">
<h2 id="remote-heading">Imported tasks</h2>
<ul>{tasks.map((task) => <li key={task.id}>{task.title}</li>)}</ul>
</section>
);
}
export default function App() {
const [userId, setUserId] = useState('1');
return (
<main>
<h1>Remote Task Manager</h1>
<label>
User
<select value={userId} onChange={(e) => setUserId(e.target.value)}>
<option value="1">User 1</option>
<option value="2">User 2</option>
<option value="3">User 3</option>
</select>
</label>
<RemoteTasks userId={userId} />
</main>
);
}
The URL uses the selected userId, and [userId, requestKey] reruns the Effect for selection changes or retry. The response.ok check handles HTTP failures before parsing JSON. Keep both cleanup mechanisms. In production, prefer route/framework loading or a client cache for caching, request deduplication, preloading, and retries.
Exit questions
- What problem does this concept solve?
- What is one common mistake?
- Can you explain the code without reading it line by line?
Recap
Effects synchronize committed UI with external systems. Dependencies list every reactive value used; cleanup mirrors setup. Events remain in handlers and derived data remains in render. Manual Vite-client fetching must check HTTP status and abort or ignore stale results; production generally benefits from framework data APIs or a client cache.
Official references
- React: Synchronizing with Effects
- React: You Might Not Need an Effect
- React:
useEffect - MDN: Using Fetch
- MDN: AbortController
Interview questions
- What qualifies as an external system, and why is an Effect not an “after render” event handler?
- Why do both
AbortControllerand an ignore/request-identity guard matter? - What are the limitations of manual fetch Effects in production?
Strong answer: Effects synchronize committed UI with external resources. Cleanup stops or invalidates obsolete work, response.ok handles HTTP failures, and a framework loader or query cache usually adds caching, deduplication, preloading, and invalidation.
Effects, stale closures, and request ownership
An Effect synchronizes with an external system; it is not a general lifecycle bucket. Do not use an Effect to calculate a value that can be derived during render or to respond to a user event that belongs in an event handler.
Own each request with AbortController or an equivalent request identity. Ignore or abort stale results, check HTTP status, validate response data, and expose loading, empty, error, retry, and success states. Compare manual fetching with a route loader or cache and explain who owns invalidation.
Interview case: server state ownership
Server state is remote, shared, asynchronous, cacheable, and potentially stale. Local state owns drafts and filters; a query cache owns fetched tasks, freshness, retries, cancellation, and invalidation. Do not copy cached tasks into useState merely to derive a filter.
If request A loads Alice's tasks and the user switches to Bob, cleanup aborts A and invalidates its continuation. Test out-of-order deferred promises plus 401, 500, empty, malformed JSON, and offline cases. Interview answer: events trigger writes, a loader/cache owns reads, React owns transient UI, and cache keys contain every result-changing input.
2026 depth expansion: the first rule of Effects is to ask whether you need one
Before writing an Effect, classify the logic:
| Logic | Correct home |
|---|---|
| derive filtered tasks | render |
| submit a form | event/action |
| update state from previous state | setter/reducer |
| synchronize a video player | Effect |
| subscribe to WebSocket | Effect |
| listen to browser event | Effect |
| fetch route data | usually router/framework/query cache |
| synchronize URL | router/navigation API |
Manual Effect-based fetching is taught because you need to understand cancellation and stale responses. It is not the default data architecture for the later production examples. Those use route loaders or TanStack Query v5.
Dependency arrays are descriptions
Do not “choose” dependencies to control execution frequency. The setup code determines the reactive dependencies. If the linter says a reactive value is missing, first change the code structure rather than silencing the rule.
The advanced Effects lesson later covers useEffectEvent from React 19.2, useLayoutEffect, external subscriptions, and removing unnecessary Effects.
Deep dive: Effects are synchronization, not application orchestration
A useful Effect sentence is:
Keep X external system synchronized with Y reactive value.
Examples:
Keep document title synchronized with current task name. Keep WebSocket room synchronized with roomId. Keep media player playback synchronized with isPlaying. Keep browser event subscription synchronized with component lifetime.
Poor Effect sentence:
Run this code after the page loads.
"After load" does not identify the synchronization contract.
The lifecycle in detail
useEffect(() => {
const connection = connect(roomId);
return () => {
connection.disconnect();
};
}, [roomId]);
Suppose:
roomId A → component commits → setup A roomId changes to B → next render commits → cleanup A → setup B component unmounts → cleanup B
Development Strict Mode additionally stress-tests setup/cleanup.
The correct goal is not "make it run once." The goal is to make every setup have a correct cleanup when cleanup is required.
Effects do not run during server rendering
Effects are client-side synchronization.
If a page's essential data only begins in an Effect:
function Page() {
useEffect(() => {
fetch('/api/page').then(...);
}, []);
return <Spinner />;
}
the server cannot render that fetched data using this Effect.
Modern routers/frameworks/server components can start data work before client Effects.
Race conditions in manual fetch
Naive:
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then(setUser);
}, [userId]);
If user rapidly changes:
A request starts B request starts B returns A returns later
stale A can overwrite B.
Use cancellation:
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
setUser(await response.json());
} catch (error) {
if (error.name !== 'AbortError') {
setError(error);
}
}
}
load();
return () => controller.abort();
}, [userId]);
This is an important learning exercise, but TanStack Query later owns this class of server-state lifecycle.
Dependency debugging
If the lint rule says:
React Hook useEffect has a missing dependency
do not immediately add:
// eslint-disable-next-line react-hooks/exhaustive-deps
Instead ask:
- Is the Effect necessary?
- Is event logic incorrectly placed in it?
- Can derived data be calculated during render?
- Is an object/function unnecessarily created outside the Effect?
- Is this genuinely non-reactive event logic that
useEffectEventshould represent? - Should the synchronization be in a custom Hook?
Object dependency
Problem:
const config = { roomId, url };
useEffect(() => {
connect(config);
}, [config]);
config is a new object every render.
Often best:
useEffect(() => {
const config = { roomId, url };
const connection = connect(config);
return () => connection.disconnect();
}, [roomId, url]);
Now dependencies are meaningful primitives.
Function dependency
Problem:
function createOptions() {
return { roomId, url };
}
useEffect(() => {
const connection = connect(createOptions());
...
}, [createOptions]);
The function identity changes every render.
Rather than automatically useCallback, put the helper inside the Effect if it only serves the Effect.
Document synchronization
useEffect(() => {
const previous = document.title;
document.title = `${task.title} · Tasks`;
return () => {
document.title = previous;
};
}, [task.title]);
Whether restoring the previous title is correct depends on the routing architecture. A router/meta system may be a better owner.
The exercise is to reason about ownership, not memorize one snippet.
Browser subscription
useEffect(() => {
function handleKeyDown(event) {
if (event.key === 'Escape') {
onClose();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
If onClose changes often, later useEffectEvent can express "subscription lifetime does not depend on this callback's latest implementation."
External widgets
useEffect(() => {
const map = new MapWidget(containerRef.current, {
center,
});
return () => {
map.destroy();
};
}, []);
Then a separate synchronization Effect can update center:
useEffect(() => {
mapRef.current?.setCenter(center);
}, [center]);
This illustrates why one giant Effect for all widget behavior can be hard to reason about.
"Run once" is not a semantic category
An empty dependency array:
useEffect(() => {
...
}, []);
means the Effect does not read reactive dependencies that should cause resynchronization.
It does not mean:
- guaranteed exactly once in development;
- safe place for arbitrary initialization;
- replacement for module initialization;
- place to send one-time payments or analytics without idempotency.
For once-per-application infrastructure, often module/provider architecture is clearer.
API loading state model
If you do manually fetch:
const [state, setState] = useState({
status: 'pending',
data: null,
error: null,
});
Distinguish:
pending error success-empty success-data
Do not use data === null to represent all four.
Effects and stale closures
Each render creates new closures.
This:
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, []);
captures initial count.
Solutions depend on semantics:
- add
countdependency and recreate timer; - use a functional state updater;
- store external mutable value in ref if UI does not depend on it;
- use
useEffectEventfor latest committed event logic.
There is no universal "stale closure fix."
Failure clinic
Effect sets state that is also a dependency
useEffect(() => {
setOptions({ sort });
}, [options, sort]);
Loop.
The real issue is duplicated state.
Fetch in every component
Multiple components manually fetching same server data leads to:
- duplicate requests;
- inconsistent retry;
- inconsistent stale data;
- no shared invalidation.
This motivates query caches.
Cleanup missing
A WebSocket/subscription that remains active after component removal creates leaks and ghost updates.
Exercises
- Convert a derived-data Effect to render calculation.
- Add AbortController to a manual fetch.
- Reproduce a stale response race and fix it.
- Build a browser resize subscription with cleanup.
- Refactor an object dependency.
- Identify five things in a project that should not be Effects.
Mastery check
Explain:
- synchronization contract;
- setup/cleanup lifecycle;
- why dependencies are descriptive;
- why manual fetch races occur;
- why Effects do not run on server;
- why query libraries reduce Effect-based server-data code.
