Gaurav Thakur
All Articles

Data Fetching Patterns in React Server Components

January 5, 2026

As developers, what we are optimizing for is basically how fast we can show meaningful content to the end user, at an optimal infra cost. When the final UI depends on some data from an external source, how we fetch that data has a direct impact on how soon we are showing the final UI to the user.

For the last decade, we have treated data fetching as a specific "moment in time."

The Client-Side Data Fetching ModelLink to heading

The pattern that we follow is that we mount a React component and render a pending state on UI, and then useEffect fires, which fetches data from our external source. Once we get data from the external source, we render the final UI.

What we notice here is that there is a request waterfall. The browser first needs to get the required JavaScript to render that component from the server. Then it has to make another request to the server to get the data required to render the UI. Only after that can it render the final UI on the user's screen.

Client-Side Rendering Timeline
HTML Shell
JS Bundle
Data Fetch
Render
Empty HTML with script tags
Download & parse JavaScript
useEffect → API call
Final UI appears

The user sees a loading spinner while JavaScript downloads and data fetches sequentially — a request waterfall.

Traditional Server-Side Rendering: Moving Data EarlierLink to heading

This was later improved with the server-side rendering technique. Now, when the initial document request comes to the server, you fetch the data even before React starts parsing the components on the server to generate the HTML.

You pass this fetched data via props to the root component, and now it is your job to push that data down from your root React component to the target component where you actually need it. So when React starts parsing this component tree to create HTML, it already has the data required to render that component.

Now, on the initial HTML request, the server sends a full-blown piece of final meaningful UI to the end user in a single request. There are no more round trips to the server to render the final expected UI. All things are done in the single request itself.

Server-Side Rendering Timeline
Server Fetch + Render
Stream HTML
Hydration
Fetch data, generate HTML on server
Full HTML sent to browser
Re-render all components for interactivity

User sees meaningful content faster, but hydration still requires downloading JS for all components.

The Tradeoffs of Server-Side RenderingLink to heading

But this approach had its own downsides.

1. Data Must Be Hoisted and Passed DownLink to heading

You always have to fetch data outside the React tree on the server and pass it down as props to the root React component. Now imagine if the component where you require this data is very deep down in the tree. You always have to pass this data via props only*.

2. Hydration and Payload CostsLink to heading

Surely, the prop-drilling part can be solved by using the Context API, where you keep a React context in the root component and wrap it in a context provider. Now you can read the context value at any depth.

But the problem is that the data we pass as props to our root React component on the server to generate the HTML string also needs to be passed to the browser. React needs this data to hydrate the HTML string on the client, and it re-renders the same JavaScript on the client that it earlier used to build the component tree on the server.

Now imagine if the data that we fetched on the server is quite big. This will bloat the JavaScript that we end up sending to the client. Even though the component where you are using this data is a dumb component and is just rendering stuff with no interactivity, React will still re-render it again in the browser, and for rendering it needs that payload. Basically, there is no way of rendering a component exclusively on the server through which we can avoid this hydration and payload cost.

Why React Server Components ExistLink to heading

So what if there could be a way to fetch data close to the component where it is actually needed, and also skip the hydration part on the client for components where no client interactivity is required by exclusively rendering these components just once on the server only.

This can be achieved with React Server Components.

Client-Side Rendering
HTML Shell
JS Bundle
Data Fetch
Render
Empty HTML with script tags
Download & parse JavaScript
useEffect → API call
Final UI appears
SSR (with Streaming)
React Server Components

CSR has the longest time to meaningful content — user sees loading spinners while JS downloads and data fetches.

In the next section, we’ll look at the most common data-fetching patterns that one can use in their app with React Server Components.

Data Fetching and Sharing Patterns in React Server ComponentsLink to heading

1. Server-Owned Data (No Client State)Link to heading

Let’s say we are building an app where we have to fetch a user’s preferences from an API and use that data across multiple server components.

Data Needed Across Server ComponentsLink to heading

The good thing about Server Components is that, unlike normal React components, they run exclusively on the server. We are sure that during the entire lifecycle of a request, a Server Component will only run once, and these components can be async in nature.

As a result, we can directly call the data-fetching logic in the nearest Server Component where we actually need the data to render the UI.

1async function ProductCard() {
2 const userPreferences = await getUserPreferences();
3
4 return (
5 <Card>
6 <Discount>{userPreferences?.maxDiscount}</Discount>
7 </Card>
8 );
9}
1async function ProductCard() {
2 const userPreferences = await getUserPreferences();
3
4 return (
5 <Card>
6 <Discount>{userPreferences?.maxDiscount}</Discount>
7 </Card>
8 );
9}

It doesn’t matter how deep this ProductCard component is in the React tree. But what if we call this component multiple times at different levels of the React tree, as shown below? Would there be duplicate fetch calls?

1async function Page() {
2 return (
3 <Layout>
4 {table.map((item) => (
5 <ProductCard key={item.key} />
6 ))}
7 <Footer>
8 <ProductCard />
9 <ProductCard />
10 </Footer>
11 </Layout>
12 );
13}
1async function Page() {
2 return (
3 <Layout>
4 {table.map((item) => (
5 <ProductCard key={item.key} />
6 ))}
7 <Footer>
8 <ProductCard />
9 <ProductCard />
10 </Footer>
11 </Layout>
12 );
13}

There are a few optimizations when it comes to data fetching in Server Components.

Fetch De-duplicationLink to heading

If you're using Next.js, it extends the native fetch API to automatically dedupe identical requests within a single render pass. So if multiple Server Components call the same fetch, only one network request actually goes out.

Interestingly, React itself used to do this. The original implementation patched the global fetch to use React.cache under the hood. But in April 2024, the React team removed this behavior after some RSC framework maintainers pushed back on it. Now it's up to frameworks like Next.js to reimplement it in userspace.

For this deduplication to kick in, the request must be truly identical — same URL, same headers, same config. In real-world apps, this is often not the case.

For example, many applications use a wrapper around fetch where default headers are added, such as an x-request-id with a unique value for every call. In some cases, the data might not even come from a fetch call at all, but directly from a database.

1async function client(endpoint: string, options: RequestConfig = {}) {
2 const headers = makeHeaders(options.headers, { "x-request-id": generateUUID() });
3 const config = makeConfig(options, { headers });
4 return fetch(endpoint, config);
5}
6
7async function getUserPreferences() {
8 const response = await client("https://api.gauravthakur.com/v1/user-config");
9 return response.json();
10}
11
12async function getOrderStatus(id: string) {
13 const order = await prisma.order.findFirst({
14 where: { id },
15 });
16 return order?.status ?? null;
17}
1async function client(endpoint: string, options: RequestConfig = {}) {
2 const headers = makeHeaders(options.headers, { "x-request-id": generateUUID() });
3 const config = makeConfig(options, { headers });
4 return fetch(endpoint, config);
5}
6
7async function getUserPreferences() {
8 const response = await client("https://api.gauravthakur.com/v1/user-config");
9 return response.json();
10}
11
12async function getOrderStatus(id: string) {
13 const order = await prisma.order.findFirst({
14 where: { id },
15 });
16 return order?.status ?? null;
17}

With this logic, if you use getUserPreferences or getOrderStatus at multiple places, the automatic deduplication won't help you. Each call to getUserPreferences produces a unique fetch because the headers are different, and getOrderStatus is custom logic that doesn't go through fetch at all.

Custom Logic De-duplicationLink to heading

To handle situations where we need to de-duplicate custom logic, React has introduced a cache API.

To make sure that a function is called only once per request cycle, we can wrap it with the cache function provided by React. With this change, our logic would look like this:

1import { cache } from "react";
2
3const getUserPreferences = cache(async () => {
4 const response = await client("https://api.gauravthakur.com/v1/user-config");
5 return response.json();
6});
7
8const getOrderStatus = cache(async (id: string) => {
9 const order = await prisma.order.findFirst({
10 where: { id },
11 });
12 return order?.status ?? null;
13});
1import { cache } from "react";
2
3const getUserPreferences = cache(async () => {
4 const response = await client("https://api.gauravthakur.com/v1/user-config");
5 return response.json();
6});
7
8const getOrderStatus = cache(async (id: string) => {
9 const order = await prisma.order.findFirst({
10 where: { id },
11 });
12 return order?.status ?? null;
13});

Now, no matter how many times we call these functions at different levels in Server Components, they will only be called once during a single render cycle.

2. Shared Data (Crossing the Server–Client Boundary)Link to heading

When a piece of data is needed inside a client boundary, the simplest thing to try first is to pass it down as a prop from the nearest server component.

One important caveat here is that we should be extra cautious when passing props from a server component to a client component. Any data that crosses this boundary needs to be serialized and sent to the browser along with the JavaScript for that client component.

React needs this data during hydration so that it can re-render the same component tree on the client. As a result, large or unnecessary props can directly increase the payload size and the amount of work done during hydration.

1// server component (nearest place where we can fetch)
2export default async function Page() {
3 const prefs = await getUserPreferences();
4 return <ClientSettingsPanel initialPrefs={prefs} />;
5}
6
7// client component
8function ClientSettingsPanel({ initialPrefs }) {
9 // use initialPrefs directly
10}
1// server component (nearest place where we can fetch)
2export default async function Page() {
3 const prefs = await getUserPreferences();
4 return <ClientSettingsPanel initialPrefs={prefs} />;
5}
6
7// client component
8function ClientSettingsPanel({ initialPrefs }) {
9 // use initialPrefs directly
10}

If the client boundary is very deep and there's no server component nearby, or you need this data throughout the app, use Context to provide the value instead of prop-drilling. Context is simple to implement and great for read-only values that won't change during the session.

1// server component
2export default async function Page() {
3 const prefs = await getUserPreferences();
4 return (
5 <PrefsProvider value={prefs}>
6 <DeepTree />
7 </PrefsProvider>
8 );
9}
10
11// client (deep)
12function SomeDeepClientComponent() {
13 const prefs = usePrefs();
14}
1// server component
2export default async function Page() {
3 const prefs = await getUserPreferences();
4 return (
5 <PrefsProvider value={prefs}>
6 <DeepTree />
7 </PrefsProvider>
8 );
9}
10
11// client (deep)
12function SomeDeepClientComponent() {
13 const prefs = usePrefs();
14}

If the data can change over time after sending it to the client, you still have two broad options to refresh the stale UI:

1. Server as the source of truthLink to heading

You can keep the server as the source of truth and invoke a server action to update the data. Once the action completes, you revalidate the client cache using your framework’s invalidation APIs (for example, revalidatePath or router.refresh in Next.js). This keeps the update flow consistent.

2. Client-managed refresh using a client cacheLink to heading

If you want to avoid a full backend refresh and instead want the client to fetch updated data by itself, you can use a client cache like React Query instead of raw Context.

In this approach, you pass the server-fetched value as initialData and provide a fetcher for subsequent updates.

1// server component: pass initial data
2export default async function Page() {
3 const prefs = await getUserPreferences();
4 return <ClientSettingsPanel initialPrefs={prefs} />;
5}
6
7// client component using react-query
8function ClientSettingsPanel({ initialPrefs }) {
9 const { data } = useQuery({
10 queryKey: ["user-prefs"],
11 queryFn: async () => {
12 const response = await fetch("/api/user-prefs");
13 return response.json();
14 },
15 initialData: initialPrefs,
16 });
17 console.log(data);
18}
1// server component: pass initial data
2export default async function Page() {
3 const prefs = await getUserPreferences();
4 return <ClientSettingsPanel initialPrefs={prefs} />;
5}
6
7// client component using react-query
8function ClientSettingsPanel({ initialPrefs }) {
9 const { data } = useQuery({
10 queryKey: ["user-prefs"],
11 queryFn: async () => {
12 const response = await fetch("/api/user-prefs");
13 return response.json();
14 },
15 initialData: initialPrefs,
16 });
17 console.log(data);
18}

This pattern works if the data is read in one place only, and you want a client side refetching or mutation. In these situations, initialData gives you a smooth transition from server-rendered data to client-managed state.

One thing to note is that if useQuery is called in a component deeper down the tree, you need to pass the same initialData all the way down to that point. A better solution would be to use

Query hydration: the more scalable patternLink to heading

In most real-world cases, where data can be required at any depth in the tree or in multiple client components, query hydration is a much more practical approach.

With hydration, you prefetch the data on the server, dehydrate the query cache, and then rehydrate it on the client. This way, all client components using the same query can access the data immediately, without manually passing initialData around.

Below is a simplified example using the latest React Query SSR pattern.

1// app/dashboard/page.tsx
2import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
3import Dashboard from "./dashboard";
4import { getUserPreferences } from "./api";
5
6export default async function DashboardPage() {
7 const queryClient = new QueryClient();
8
9 await queryClient.prefetchQuery({
10 queryKey: ["user-preferences"],
11 queryFn: getUserPreferences,
12 });
13
14 return (
15 <HydrationBoundary state={dehydrate(queryClient)}>
16 <Dashboard />
17 </HydrationBoundary>
18 );
19}
1// app/dashboard/page.tsx
2import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
3import Dashboard from "./dashboard";
4import { getUserPreferences } from "./api";
5
6export default async function DashboardPage() {
7 const queryClient = new QueryClient();
8
9 await queryClient.prefetchQuery({
10 queryKey: ["user-preferences"],
11 queryFn: getUserPreferences,
12 });
13
14 return (
15 <HydrationBoundary state={dehydrate(queryClient)}>
16 <Dashboard />
17 </HydrationBoundary>
18 );
19}

Here, we fetch the data on the server and dehydrate the query cache. At this point, the server is done — no props, no initialData, no prop drilling.

1// app/dashboard/dashboard.tsx
2"use client";
3
4import { useQuery } from "@tanstack/react-query";
5import { getUserPreferences } from "./api";
6
7export default function Dashboard() {
8 const { data: preferences } = useQuery({
9 queryKey: ["user-preferences"],
10 queryFn: getUserPreferences,
11 });
12
13 return (
14 <>
15 <Header />
16 <SettingsPanel />
17 </>
18 );
19}
20
21function Header() {
22 const { data: preferences } = useQuery({
23 queryKey: ["user-preferences"],
24 queryFn: getUserPreferences,
25 });
26
27 return <span>Max Discount: {preferences?.maxDiscount}</span>;
28}
29
30function SettingsPanel() {
31 const { data: preferences } = useQuery({
32 queryKey: ["user-preferences"],
33 queryFn: getUserPreferences,
34 });
35
36 return <div>{/* render preferences */}</div>;
37}
1// app/dashboard/dashboard.tsx
2"use client";
3
4import { useQuery } from "@tanstack/react-query";
5import { getUserPreferences } from "./api";
6
7export default function Dashboard() {
8 const { data: preferences } = useQuery({
9 queryKey: ["user-preferences"],
10 queryFn: getUserPreferences,
11 });
12
13 return (
14 <>
15 <Header />
16 <SettingsPanel />
17 </>
18 );
19}
20
21function Header() {
22 const { data: preferences } = useQuery({
23 queryKey: ["user-preferences"],
24 queryFn: getUserPreferences,
25 });
26
27 return <span>Max Discount: {preferences?.maxDiscount}</span>;
28}
29
30function SettingsPanel() {
31 const { data: preferences } = useQuery({
32 queryKey: ["user-preferences"],
33 queryFn: getUserPreferences,
34 });
35
36 return <div>{/* render preferences */}</div>;
37}

With this setup:

  1. Data prefetched on the server is available immediately on the client
  2. useQuery can be called at any depth
  3. Multiple components can safely read the same query
  4. Client-only queries and server-prefetched queries can coexist

This pattern scales much better as the application grows and avoids the DX issues of passing initialData manually.

ConclusionLink to heading

We started with a simple goal, showing meaningful content to users as fast as possible while keeping infra costs sane. The arrival of Server Components changes the problem from “when do I fetch?” to “where should data live, who owns it, and how do we move it across the server–client boundary?”

Quick recap of the patterns we covered:

  1. Server-owned data — fetch near the consuming server component; use cache to dedupe custom logic when the same data is needed multiple times in a single render.
  2. Client-owned data — local state, context, or a client cache when the data is session-scoped or mutable in the browser.
  3. Shared data (crossing the boundary) — prefer props from the nearest server component; fallback to Context for simple, read-only values at depth; use server actions + revalidation when the server should remain authoritative; use a client cache (React Query) when you want the client to manage refreshes without a full backend revalidation, and prefer hydration over initialData when the same query is used in many places.