Fetching Data
By the end of this lesson
Load data and keep it in step with what the user is viewing.
Data usually lives somewhere else: a database behind an API, a search service, another team's endpoint. Getting it into a component is a request across a network, which means it takes time, it can fail, and the answer can arrive after you have stopped caring about the question.
There are two places to make that request. In a Next.js application, the first choice is a server component, which fetches before any HTML is sent and needs no state, no effect and no loading flag. The second is an effect in a client component, which is what you need when the request depends on something the user is doing right now.
This lesson covers both, and spends most of its time on the failure that only appears when requests overlap.
// app/employees/page.tsx — a server component. No hooks involved.
interface Employee {
id: string;
name: string;
department: string;
}
async function getEmployees(): Promise<Employee[]> {
const response = await fetch("https://api.example.com/employees", {
headers: { Authorization: "Bearer " + process.env.DIRECTORY_API_TOKEN },
next: { revalidate: 300 },
});
if (!response.ok) {
throw new Error("Employee directory request failed: " + response.status);
}
return response.json();
}
export default async function EmployeesPage() {
const employees = await getEmployees();
return <EmployeeTable employees={employees} />;
}- The component is an async function and awaits the data. Next.js waits for it before sending HTML, so the page arrives with the employees already in it.
- There is no loading state here because there is no moment when the component is rendered without data. That is the main reason to prefer this shape when the data does not depend on client-side interaction.
- The API token is read from an environment variable on the server. This code never reaches the browser, so the token is not exposed — which is a property of where the component runs, and the deployment lesson explains the rules around it.
- next: { revalidate: 300 } asks Next.js to cache the result for five minutes. The caching lesson goes into what that means and when it is the wrong choice.
- Throwing on a failed response hands the problem to the nearest error boundary rather than rendering a page built from undefined. The Loading and Error States lesson covers what the user then sees.
"use client";
import { useEffect, useState } from "react";
// Looks reasonable. Has a race condition.
export function EmployeeSearchResults({ term }: { term: string }) {
const [results, setResults] = useState<Employee[]>([]);
useEffect(() => {
async function load() {
const response = await fetch("/api/employees?q=" + encodeURIComponent(term));
const data = await response.json();
setResults(data);
}
load();
}, [term]);
return <EmployeeTable employees={results} />;
}- The effect depends on term, so it re-runs whenever the search term changes. That part is right.
- encodeURIComponent is not optional. A term containing an ampersand or a hash would otherwise change the meaning of the URL.
- What is missing is any handling of the previous request. Each keystroke starts a new one, and nothing stops an earlier one from finishing later and calling setResults with its own data.
Here is how that turns into a bug report saying "the search shows the wrong results":
The user types "ad"
The effect runs and a request for "ad" goes out. It matches many employees, so the server takes 800ms to answer.
The user keeps typing: "adi"
The effect runs again and a second request goes out. This one matches fewer people and comes back in 120ms.
The "adi" response arrives first
setResults is called with the correct, narrow list. The screen is right. Everything looks fine.
The "ad" response arrives 700ms later
Its setResults call also runs, because nothing told it not to. The narrow list is replaced by the wider one.
What the user sees
A search box containing "adi" and a list of results for "ad". Nothing on screen explains it, no error is logged, and it only reproduces when the responses happen to overtake each other — which is why it is usually reported as intermittent.
Why the last request is not necessarily the last response
Response time depends on the work each query causes, network conditions and server load. Requests are not a queue. Order of departure tells you nothing about order of arrival.
"use client";
import { useEffect, useState } from "react";
export function EmployeeSearchResults({ term }: { term: string }) {
const [results, setResults] = useState<Employee[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Cancels the request itself when this effect is cleaned up.
const controller = new AbortController();
// Belt and braces: ignore a response that resolves during teardown.
let ignore = false;
async function load() {
try {
const response = await fetch("/api/employees?q=" + encodeURIComponent(term), {
signal: controller.signal,
});
if (!response.ok) throw new Error("Search failed: " + response.status);
const data: Employee[] = await response.json();
if (!ignore) {
setResults(data);
setError(null);
}
} catch (caught) {
// An aborted request is expected, not a failure to report.
if (caught instanceof Error && caught.name === "AbortError") return;
if (!ignore) setError("Could not load results. Try again.");
}
}
load();
return () => {
ignore = true;
controller.abort();
};
}, [term]);
if (error) return <p role="alert">{error}</p>;
return <EmployeeTable employees={results} />;
}- The cleanup runs before the effect runs again. So by the time the second request starts, the first has been aborted and its ignore flag is set — whatever it does afterwards cannot reach state.
- ignore is declared inside the effect, so each run has its own. That is what makes it work: the variable belongs to one request, not to the component.
- Aborting makes fetch reject with an AbortError. Catching it and returning is important, or every keystroke logs an error that is not one.
- Checking response.ok matters because fetch resolves normally for a 404 or a 500. Only a network-level failure rejects, so without this check you would parse an error page as JSON.
- The same cleanup covers unmounting. Navigate away mid-request and the response arrives to a component that no longer exists, and without the guard you are setting state on nothing.
Deciding where a fetch belongs. Most screens want the first row and reach for the others deliberately:
- In a server component
- The data is needed to render the page and does not change with client-side interaction. No loading state, no effect, no API token in the browser, and less JavaScript shipped. This is the default in a Next.js app.
- In an effect in a client component
- The request depends on something happening in the browser: a search term, a filter, an item the user expanded. Needs the cleanup shown above.
- In an event handler
- A one-off request caused by an action — submitting a form, deleting an employee. It is not a synchronisation, so it does not belong in an effect.
- In a data-fetching library
- Once several screens need caching, retries, deduplication and background refresh, a library that does all of it is less code than maintaining your own. Understand the effect version first, because the library's behaviour only makes sense against it.
Summary
- Prefer fetching in a server component: no loading state, no effect, and credentials stay on the server
- Fetch in an effect when the request depends on client-side interaction, and in a handler when it is caused by an event
- Overlapping requests can resolve out of order, showing results for a query the user has left behind
- An ignore flag discards a superseded response; an AbortController also cancels the work
- fetch resolves for error statuses, so check response.ok before parsing
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Take the fixed search component and add a 300ms debounce, so a request is only sent once the user pauses.
Then check the two things that break if you get it wrong: typing quickly should produce one request rather than one per character, and the timer must not survive the component being removed.
Show solution
The timer is started inside the effect and cleared in the same cleanup that aborts the request. One cleanup handles both, because both were created by this run of the effect.
Clearing the timeout is what produces the debounce. Each keystroke changes term, which cleans up the pending timer before it fires, so only a pause long enough to let it elapse results in a request.
The abort is still needed. Debouncing reduces the number of requests; it does not make them arrive in order. A slow request followed 300ms later by a fast one can still overtake it.
A deliberate detail: term is not debounced in state, only the request is. The input stays instantly responsive because it renders from the undelayed value, while the network work waits. Debouncing the displayed value instead would make typing feel laggy.
useEffect(() => {
const controller = new AbortController();
let ignore = false;
const timer = setTimeout(async () => {
try {
const response = await fetch("/api/employees?q=" + encodeURIComponent(term), {
signal: controller.signal,
});
if (!response.ok) throw new Error("Search failed");
const data: Employee[] = await response.json();
if (!ignore) setResults(data);
} catch (caught) {
if (caught instanceof Error && caught.name === "AbortError") return;
if (!ignore) setError("Could not load results. Try again.");
}
}, 300);
return () => {
ignore = true;
clearTimeout(timer);
controller.abort();
};
}, [term]);Think about it
Think about it
A team fixes the race condition by keeping the latest term in a ref and comparing it to the term the response was for, discarding any mismatch.
Does that work? What does it cost compared with the cleanup approach?
Show solution
It works for the specific symptom. Comparing the response's term against the current one does filter out the stale result.
What it costs is everything else the cleanup gave you. The old request is never cancelled, so the server keeps working and the connection stays open. Unmounting is not handled, so a response arriving after navigation still tries to set state. And the correctness now depends on a ref that some future change has to remember to keep accurate, rather than on a variable that belongs to one request and cannot be got wrong.
There is a deeper point. The cleanup version says "this effect had a request, and the request is over now", which is true of every request the component makes. The ref version encodes a specific comparison for a specific field, and has to be reworked when the request depends on two fields instead of one.
That said, a per-request identity check is not wrong in itself — a data-fetching library does something similar internally. The difference is that the library also handles cancellation, unmounting and deduplication, which is the argument for using one rather than reinventing part of it.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.