open atlas
↑ Back to track
React patterns, senior RXP · 13 · 01

RSC fetch vs client cache

In the App Router, await data in a Server Component for initial per-request fetches with no client cache; reach for a query library (TanStack Query/SWR) only when you need client cache semantics — refetch, dedupe, mutate, optimistic, polling.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The first thing every React app does is fetch data, and it’s the place the App Router changed the most. The old reflex — drop a useEffect, set loading, set error, set data — still runs, still compiles, and is now usually the wrong answer. You have two better tools and the senior skill is knowing which one a fetch is asking for.

One tool runs on the server and awaits straight into your JSX. The other runs on the client and gives you a cache with refetch, dedupe, and mutations. They are not competitors — they own different jobs. Pick by the kind of fetch, not by habit.

Goal

After this lesson you can decide where a fetch belongs: use RSC fetch (await in a Server Component) for per-request initial data that needs no client-side cache; reach for a client cache library (TanStack Query or SWR) when you need refetch, request dedupe, background revalidation, polling, mutations, or optimistic updates. You can write both, hand server data into the client cache as initial data, and explain why fetching in useEffect is the failure mode when either of the first two is available.

1

For initial, per-request data, fetch on the server: await directly in a Server Component. In the App Router a component is a Server Component by default. It can be async, await your data, and render the result — the fetch happens during the request, before any HTML reaches the browser, with no loading spinner, no useEffect, and no client JavaScript shipped for the fetch.

// app/orders/page.tsx — a Server Component (no "use client")
async function OrdersPage() {
  const orders = await getOrders();         // runs on the server, per request
  return (
    <ul>
      {orders.map((o) => <li key={o.id}>{o.total}</li>)}
    </ul>
  );
}
export default OrdersPage;

No client cache exists here and none is needed: the data is computed fresh per request and embedded in the HTML. This is the default you should reach for first.

2

Reach for a client cache library when you need client cache semantics, not just data. RSC await gives you a value once. A query library (TanStack Query, SWR) gives you a cache keyed by query key: it dedupes concurrent requests, serves stale data instantly while revalidating in the background, refetches on focus/reconnect, polls on an interval, and exposes loading/error/isFetching without you wiring them. None of that exists in a one-shot server await.

"use client";
import { useQuery } from "@tanstack/react-query";

function Orders() {
  const { data, isPending, error, refetch } = useQuery({
    queryKey: ["orders"],
    queryFn: getOrders,
    staleTime: 30_000,            // serve cached for 30s before revalidating
    refetchOnWindowFocus: true,   // background-refresh when the tab regains focus
  });
  if (isPending) return <Spinner />;
  if (error) return <Error onRetry={refetch} />;
  return <OrderList orders={data} />;
}

If two components ask for ["orders"], the library fires one request and shares it. That dedupe + cache is the whole reason to pull a client library in.

3

Mutations, optimistic updates, and polling are squarely client-cache territory. A Server Component renders once and is gone — it can’t re-run on a button click. The moment the UI must change data and reflect it, you need a client cache so the mutation can invalidate or patch the cached query and trigger a re-render. TanStack’s useMutation (or SWR’s mutate) snapshots the cache, applies an optimistic value, and rolls back on error.

"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";

function ToggleFavorite({ id }: { id: string }) {
  const qc = useQueryClient();
  const m = useMutation({
    mutationFn: () => toggleFavorite(id),
    onMutate: async () => {
      await qc.cancelQueries({ queryKey: ["orders"] });
      const prev = qc.getQueryData(["orders"]);
      qc.setQueryData(["orders"], optimisticToggle(id));  // instant UI
      return { prev };
    },
    onError: (_e, _v, ctx) => qc.setQueryData(["orders"], ctx?.prev), // rollback
    onSettled: () => qc.invalidateQueries({ queryKey: ["orders"] }),  // reconcile
  });
  return <button onClick={() => m.mutate()}>★</button>;
}

Polling (refetchInterval) and refetchOnReconnect are the same story: continuous, client-driven freshness an RSC simply can’t provide.

4

Fetching in useEffect is the failure mode whenever RSC or a query library is available. The classic useEffect(() => { fetch().then(setData) }, []) re-implements a cache library badly: it races (a stale response can land after a newer one and overwrite it unless you track an ignore flag), it never dedupes (every mount refetches), it has no cache (navigating away and back refetches from scratch), and it forces you to hand-wire loading/error every time. React’s own docs flag data-fetching-in-effects as something you usually don’t need.

// ❌ the anti-pattern this lesson exists to retire
useEffect(() => {
  let ignore = false;                       // the race-condition band-aid
  setLoading(true);
  fetch(`/api/orders`)
    .then((r) => r.json())
    .then((d) => { if (!ignore) setData(d); })
    .catch((e) => { if (!ignore) setError(e); })
    .finally(() => { if (!ignore) setLoading(false); });
  return () => { ignore = true; };
}, []);                                      // no cache, no dedupe, manual everything

Use an effect for fetching only when you have neither a server component (e.g. a pure SPA without RSC) nor a query library — and even then, the query library is usually the smaller total cost.

Worked example

An orders screen that needs both server-fast initial paint and client refetch + mutation. The naive client-only version fetches in an effect and pays for it on every visit.

// ❌ before: client-only, effect fetch — slow first paint, races, no cache
"use client";
function Orders() {
  const [data, setData] = useState<Order[] | null>(null);
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    let ignore = false;
    getOrders().then((d) => !ignore && (setData(d), setLoading(false)));
    return () => { ignore = true; };
  }, []);
  if (loading) return <Spinner />;
  return <OrderList orders={data!} />;
}

The senior arrangement fetches once on the server for instant first paint, then hands that result into TanStack Query as initial data so the client owns refetch, mutations, and polling — with no second loading spinner and no waterfall.

// ✅ after: Server Component awaits initial data, client cache takes over
// app/orders/page.tsx (Server Component)
async function OrdersPage() {
  const initial = await getOrders();          // fast, per-request, in the HTML
  return <OrdersClient initialOrders={initial} />;
}

// orders-client.tsx (Client Component)
"use client";
function OrdersClient({ initialOrders }: { initialOrders: Order[] }) {
  const { data } = useQuery({
    queryKey: ["orders"],
    queryFn: getOrders,
    initialData: initialOrders,               // no client loading flash
    staleTime: 30_000,
    refetchOnWindowFocus: true,               // client cache semantics
  });
  return <OrderList orders={data} />;
}

Server await owns the first render; the query library owns every render after. That’s the seam: RSC for initial data, client cache for the live, interactive lifecycle — and useEffect for fetching appears nowhere.

Why this works

Why prefer the server await for initial data instead of always using the query library on the client? Three reasons. It ships zero fetch JavaScript and zero waterfall for the first paint — the data is in the HTML, so there’s no spinner→fetch→render round trip. It keeps secrets and heavy queries on the server (no exposed API surface, no client bundle for the data layer). And it’s simpler: one await, no query keys, no provider, until you actually need cache semantics. The query library is a tool you add when the interaction demands it, not the default for reading data.

Common mistake

The most common over-correction is using a Server Component for data that’s inherently interactive — a live dashboard that must poll, a list with optimistic toggles, a search that refetches as you type. RSC can render that data once, but it can’t refetch, dedupe, mutate, or poll; trying to force it leads to full route reloads or, worse, a smuggled useEffect. The rule cuts both ways: don’t reach for the query library to read static initial data, and don’t try to make an RSC do a client cache’s job. Match the tool to whether the data needs a living cache after first paint.

Check yourself
Quiz

A widget shows a live order count that must auto-refresh every 10 seconds and update instantly when the user clicks 'mark fulfilled'. In an App Router app, where does this fetch belong?

Recap

Two questions decide where a fetch lives. Is it initial, per-request data that needs no client cache? Then await it in an async Server Component — it runs server-side, lands in the HTML, ships no fetch JS, and keeps secrets server-only. Does it need cache semantics — refetch, request dedupe, background revalidate, polling, mutations, optimistic updates? Then use a client cache library (TanStack Query or SWR), keyed by query key, optionally seeded with RSC initial data so first paint stays fast. The two compose: server await owns the first render, the client cache owns the interactive lifecycle after it. The failure mode is fetching in useEffect when either is available — it re-implements a cache library badly: race conditions, no dedupe, no cache, manual loading/error wiring. Pick by the kind of fetch, not by habit: initial data → server; living data → client cache; effect-fetch → last resort.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.