Fetching data
await in the component – and how to avoid waterfalls
In a server component you fetch with await, right there. No useEffect, no loading state, no race – but a new trap: loads that run one after another for no reason.
This is where Next.js is most obviously simpler than plain React. What took three states and an effect there is one line here.
"use client";
function List() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let current = true;
fetch("/api/posts")
.then((r) => r.json())
.then((d) => current && setData(d))
.catch(setError)
.finally(() => setLoading(false));
return () => { current = false; };
}, []);
if (loading) return <p>Loading …</p>;
…
} export default async function List() {
const posts = await db.post.findMany();
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
} No loading state, because the user only sees the page once the data is there. No race, because nothing runs in parallel in the browser. And no API in between, because you are already on the server.
fetch to a third-party API, a database call, the file system – all allowed. The code runs on the server, so the server's possibilities apply.
The new trap: the waterfall
Two awaits in a row mean the second only starts when the first finishes. If they know nothing about each other, that is wasted time – a WaterfallTwo loads running one after the other although they could run side by side – the most common slowdown.→.
const user = await loadUser(id);
const posts = await loadPosts(); // waits for no reason const [user, posts] = await Promise.all([
loadUser(id),
loadPosts(),
]); Sequential is only right when the second call needs the first one's result – loadOrders(user.id), for instance.
Instead of collecting everything before anything appears, you can let each part fetch for itself and wrap it in <Suspense>. Then every area shows up as soon as it is ready. There is a lesson on streaming for that.
The same request more than once
If the layout and the page both need the signed-in user, you simply write the call twice. Next.js still only runs it once – that is Request memoizationThe same `fetch` used several times while building one page only really runs once.→.
// app/layout.tsx
const user = await loadUser(); // performs the request
// app/page.tsx
const user = await loadUser(); // reuses the same result
// Automatic for fetch. For your own functions:
import { cache } from "react";
export const loadUser = cache(async () => { … }); Where data belongs
- +
In the component that displays it – even deep in the tree
- +
In the layout, if it applies to the whole area (menu, user)
- +
In a shared function wrapped in
cache()when several need it
- −
In a
useEffect– that is the detour you just got rid of - −
In your own API route, only to call it yourself afterwards
- −
Right at the top, then threaded down through five levels
A common reflex: build a route.ts first, then call it from the server component with fetch("/api/…"). That is a network detour to yourself. Call the function directly. You only need a Route handlerA `route.ts` that answers an address without being a page – your way to a custom API.→ when something outside wants access.
▸ In more depth: client components that need data optional
A client component may not be async and cannot touch the database. It has three routes – in this order:
1. Pass it down
A server component fetches and hands the result over as a prop.
Covers the vast majority of cases.
2. Call a server action
For data only needed once the user does something.
3. Fetch in the browser
If it genuinely has to be client-side: TanStack Query or similar
against a route handler. Very often the boundary can simply be drawn elsewhere: the client component gets smaller, the fetching stays on the server. That is almost always better than a second loading layer in the browser.
Does it stick?
5 questions on this lesson. Wrong answers show up in your stats.