Skip to content
intermediate Phase 5 · Data Fetching and Caching

Client-Side Fetching with SWR

Load data interactively with caching and revalidation.

45m
0 problems
Topic Progress 0%

When the Data Follows the User: Fetching in Effects for Interaction-Driven Reads

The list is on the server. Then the user types three characters into the filter box, drags a date range, or scrolls past page 1 — and the server, which rendered this HTML before any of that happened, has nothing to say. This is the domain of the client effect: data whose identity is created by interaction the server could not predict.

'use client';
import { useEffect, useState } from 'react';

export function SearchResults({ query }: { query: string }) {
  const [rows, setRows] = useState<Row[] | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (query.length < 3) { setRows(null); return; }   // gate the request
    const ac = new AbortController();                  // cancellation
    setError(null);
    fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: ac.signal })
      .then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
      .then(setRows)
      .catch((e) => { if (e.name !== 'AbortError') setError(e); });
    return () => ac.abort();                           // cleanup on next keystroke
  }, [query]);

  if (error) return <p role="alert">Search failed — retry.</p>;
  if (rows === null) return <p>Type at least 3 characters…</p>;
  return <Table rows={rows} />;
}

Every clause in that effect is load-bearing, and each is a classic interview follow-up:

Line What breaks without it
query.length < 3 gate fires a request per keystroke for useless input
AbortController + cleanup out-of-order responses: slow request B overwrites fresh request A
e.name !== 'AbortError' check an aborted request surfaces as a fake error
[query] dependency effect never re-runs, UI shows stale results
explicit error state failures render an empty table indistinguishable from "no matches"

The race is the one worth being able to explain out loud. Type rea, then react quickly: without cancellation, the slower rea response can land after react and repaint the list with old results. Abort solves it at the source; an "ignore stale responses" flag with a version counter in the closure is the manual equivalent, and it is what older codebases contain.

Notice what this component does not do: fetch the initial rows. The parent server component passes them (or renders the first page itself), so first paint has data and the effect only handles changes. That split — server for the initial read, client for the interactive ones — is the placement rule from Fetching Data in Async Components, now seen from the client side.

What a raw effect is missing, and why teams stop writing them by hand: a cache shared between components (two widgets with the same query each fetch), deduplication while a request is in flight, re-fetch when the tab regains focus, retry with backoff on failure, and a loading flag that distinguishes "first load" from "updating". Reimplementing that per component is how a codebase accumulates forty slightly different useEffect fetchers — which is precisely the problem a data library exists to solve.

Before reaching for a library, though, ask the placement question first — because the most expensive version of this pattern is a client effect fetching data the server could have shipped: useEffect(() => { fetch('/api/orders') }, []) in a component that has access to the server is a round trip you added for no reason, plus a spinner and an empty first paint. The effect above earns its place only because its query changes with keystrokes. Server first, effect second — in that order, every time.

Why Teams Reach for SWR: Keyed Cache, Dedupe, and Revalidation

SWR (stale-while-revalidate, named after the HTTP header) turns "fetch in an effect" into "declare a key and a fetcher". The library owns the cache, the in-flight dedupe, the retries, and the revalidation triggers; your component owns rendering.

'use client';
import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export function PriceTicker({ productId }: { productId: string }) {
  const { data, error, isLoading, isValidating } = useSWR(
    `/api/products/${productId}/price`,   // cache key
    fetcher,
    { refreshInterval: 30_000 },          // poll while mounted
  );
  if (error) return <button onClick={mutate}>Retry</button>;
  if (isLoading) return <Skeleton />;
  return <span>{data.price}</span>;
}

What you get for that declaration:

  • Deduplication — two components mounting with the same key in the same tick share one request.
  • Cache-first render — if the key is already cached, data is available immediately with no loading flash; a background revalidation then updates it.
  • Automatic triggers — revalidate on mount, on window focus (the user returns to a tab showing stale prices), on network reconnect, and on an interval you set.
  • Retry with backoff on failure, plus a distinct error state so you never confuse "failed" with "empty".
  • Global cache — mutate('/api/...') from anywhere updates every component using that key.

The key is the API. Make it stable and specific: ['/api/search', query] (arrays serialize to a canonical form) so two widgets with the same inputs share a cache entry and two different inputs never collide. Keys can be dependent by being null, which suspends fetching — the classic pattern for a query that only exists once its parent id is known:

const { data } = useSWR(
  userId ? `/api/users/${userId}/orders` : null,
  fetcher,
);

Mutations are where the cache pays for itself. Instead of hand-setting state, you invalidate the key and let SWR refetch:

import useSWR, { mutate } from 'swr';

async function archiveOrder(id: string) {
  await fetch('/api/orders/' + id, { method: 'PATCH', body: JSON.stringify({ archived: true }) });
  mutate('/api/orders');            // revalidate every consumer of the list key
}

React Query (TanStack Query) is the same idea with more knobs — useQuery/useMutation, staleTime controlling when data is considered old, and invalidateQueries for the same refetch — plus first-class mutation lifecycle (onSuccess, optimistic updates, rollbacks). Pick either; interviewers care that you can name the difference between cache freshness (staleTime) and refetch triggers (focus, interval, invalidation), because confusing them produces apps that either poll needlessly or never update.

The one thing neither library fixes: cold first paint. SWR's cache starts empty in the browser, so a page whose data lives only in the client renders a spinner after hydration even though the server knew the data a moment ago. That gap is the next chapter's problem.

Choosing the key deserves one more example, because key design is component design here. A component that reads a URL param, a filter, and a sort order should compose all three: ['/api/products', { q, category, sort }] — SWR serializes arrays canonically, so two instances of the widget with the same inputs share one cache entry and one request, while changing the sort produces a distinct entry (which is what makes "change sort back" instant — the previous result is still cached). Bad keys are just as visible: a key containing Date.now() refetches forever, and a key built from an object literal that serializes in different orders ({a:1,b:2} vs {b:2,a:1}) quietly splits one entry into two.

Seeding the Client from the Server: Killing the Post-Hydration Double Fetch

The failure looks like this: the server-rendered HTML contains the orders, and then — immediately after hydration — the list flashes to a skeleton and comes back. Two renders, one wasted request, a visible CLS bump. The cause: the client library mounted with an empty cache, decided the key was missing, and fetched data the server already had.

The fix is to hand the server's result to the client as the cache's initial value. The server component does the fetch; the client component receives it and seeds the cache with the same key.

// app/orders/page.tsx  (server)
export default async function Page() {
  const orders = await getOrders();                 // one read, server-side
  return <OrdersTable initialOrders={orders} />;    // client component below
}

// components/OrdersTable.tsx  (client)
'use client';
import useSWR from 'swr';

export function OrdersTable({ initialOrders }: { initialOrders: Order[] }) {
  const { data } = useSWR('/api/orders', fetcher, {
    fallbackData: initialOrders,     // seed: renders immediately, no spinner
    revalidateOnMount: false,         // trust the seed until focus/interval/refetch
  });
  return <Table rows={data} />;
}

SWR also accepts a whole cache map via <SWRConfig value={{ fallback: { '/api/orders': orders } }}> — useful when many components deep in the tree need seeds, since each one can declare its key and still render from the provider's map.

React Query's version is initialData plus staleTime: with staleTime: 30_000, a mount within thirty seconds renders the seed without refetching, and after it expires the next trigger does the network call. The same shape exists across frameworks (dehydrate/hydrate for serialized caches) because the problem is universal: the server already paid for this data; make the client reuse it.

A checklist for the boundary, since this is a favorite systems question:

  1. Same key on both sides. A mismatch (server writes orders, client reads orders:list) means the seed is never found and the double fetch returns.
  2. Serializable data only. What crosses is JSON — no Dates (they become strings), no class instances, no functions. Parse at the boundary or keep the payload boring.
  3. Authorization still applies. The seed is only as trustworthy as the page that rendered it; never seed a private key into a public layout's props.
  4. Know when to refetch anyway. Focus revalidation and intervals are features — revalidateOnMount: false disables the initial fetch, not later ones, and leaving it on with a fresh seed costs one duplicate request rather than a spinner.
  5. Interaction reads stay client-side. Seeding covers the first render; the filter box still needs the effect-or-SWR path from the first two chapters.

The measurable payoff: one request instead of two on load, no skeleton flash, and a hydration pass that renders the same tree the server sent — which is exactly what React wants for avoiding hydration mismatches. When a client component's first render disagrees with the HTML (different data shape, different key), React warns and rebuilds the subtree, so keeping the seed byte-identical to what the server used is not just an optimization.

There is a subtle variant worth knowing: seeding with a skeleton instead of data. Passing null as the fallback and rendering your own placeholder gives the client something to show while its first fetch runs — a legitimate choice when the server genuinely cannot provide the value (the data is behind a client-only token). The distinction to hold onto is that a seeded render needs no request at all, while a placeholder render is just a prettier spinner: both avoid a blank screen, only one avoids the network. Ask which one you are building before congratulating yourself for the skeleton.

Quiz

1. A search box fetches in useEffect on every keystroke and occasionally shows results for a previous query. What is the root cause and the standard fix?

Question 1 options

2. Two components mount in the same render pass using the same SWR key. What happens?

Question 2 options

3. After hydration, a client-fetched list flashes to a skeleton and refetches data the server had. Best fix?

Question 3 options

4. Which read belongs in a client effect rather than an async server component?

Question 4 options

Flashcards

Question

What does useEffect fetching give you, and what are its four mandatory safeguards?

Answer

It handles interaction-driven data the server can't predict. Safeguards: gate the request (e.g. min query length), AbortController returned from the effect to kill stale responses, ignore AbortError when setting error state, and a correct dependency array — plus an explicit error state so failures don't look like empty results.

Question

What do SWR/React Query add over a hand-written effect?

Answer

Keyed global cache (cache-first render), concurrent request dedupe, revalidation triggers (mount, focus, reconnect, interval), retry with backoff, distinct error/isLoading/isValidating states, and mutation APIs (mutate / invalidateQueries) that update every consumer of a key.

Question

How do you express a dependent query key in SWR?

Answer

Return null while the dependency is missing: `useSWR(userId ? '/api/users/' + userId + '/orders' : null, fetcher)` — a null key suspends fetching, so no request fires until the parent value exists. Keys should be stable and identical across components that should share cache.

Question

Why does a client list flash a skeleton after hydration, and how do you fix it?

Answer

The client cache starts empty, so the component refetches data the server already rendered. Fix: seed the cache with the server's value under the exact same key — SWR fallbackData or SWRConfig fallback, React Query initialData with staleTime — optionally revalidateOnMount: false. Serialize only JSON-safe values.

Question

Division of labor: server fetch vs client fetch in one sentence.

Answer

Initial page data and anything derived from the URL/metadata comes from the server (fast first paint, no spinner, secrets hidden); reads created by user interaction — search, filters, infinite scroll, live previews — come from the client via a keyed cache library.

Revision Notes

Key Takeaways

  • 1. Client effects exist for data created by interaction — and they need cancellation, gating, dependency discipline, and a real error state
  • 2. SWR/React Query replace forty bespoke fetchers with a keyed cache: dedupe, focus/reconnect/interval revalidation, retries, and shared mutation invalidation
  • 3. The key is the API: stable, specific, shared by components that should share state, and null when a dependency is missing
  • 4. Server-rendered HTML must be seeded into the client cache under the same key or hydration refetches what you already sent
  • 5. Freshness (staleTime / revalidateOnMount) and refetch triggers (focus, interval, mutate) are separate knobs — confusing them means needless polling or dead UI

Interview Tips

  • • "When do you fetch in the browser?" — search-as-you-type, infinite scroll, sockets, previews; state clearly that first-paint data stays on the server
  • • "Race condition in a search box?" — explain stale response overwriting fresh, then AbortController in effect cleanup (or a version counter) as the fix
  • • "Why does my list flash after hydration?" — empty client cache vs server HTML; seed with fallbackData/initialData under an identical key, mention hydration mismatch if the seed differs
  • • "SWR vs React Query?" — same model (keyed cache, revalidation, invalidation); cite staleTime vs revalidateOnMount and ecosystem/mutation-lifecycle differences as the deciding factors

Cheat Sheet

Client-Side Fetching with SWR

Placement rule: initial data -> server component; interaction-driven data -> client.

Effect template (when you must write one):

useEffect(() => {
  if (query.length < 3) { setRows(null); return; }
  const ac = new AbortController();
  fetch(`/api/search?q=${query}`, { signal: ac.signal })
    .then(r => { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
    .then(setRows)
    .catch(e => { if (e.name !== 'AbortError') setError(e); });
  return () => ac.abort();       // cancels the previous request on re-run
}, [query]);

SWR in one block:

const { data, error, isLoading, isValidating } = useSWR(key, fetcher, {
  refreshInterval: 30_000,
  fallbackData: serverData,      // seed -> no post-hydration spinner
  revalidateOnMount: false,      // optional: trust the seed initially
});
mutate(key);                     // refetch + update every consumer

Key design: stable, specific, identical server and client; arrays serialize canonically; null key = suspended fetch (dependent queries).

React Query equivalents: useQuery/useMutation, initialData + staleTime for seeding, invalidateQueries for refetch.

Hydration checklist: same key both sides · JSON-serializable seed only · authorization respected · seed matches server HTML byte-for-byte to avoid hydration mismatch · focus/interval revalidation still runs.

Continue Learning