modern-react-spa

Chapter 14

Server State — TanStack Query (React Query)

TanStack Query (React Query) for server state in React 19.2 — cache-key-as-contract, optimistic mutations, prefetching, devtools, and pairing with Zustand.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can model server data with TanStack Query’s cache-key-as-contract pattern, write mutations that optimistically update and roll back on failure, prefetch and stream paginated lists, debug a stale cache with the devtools, and pair TanStack Query with Zustand without duplicating state.

🧭 Prerequisites — Ch 12 (server cache is one of the four kinds). Ch 2 §2.2 (Actions / useOptimistic) — TanStack Query’s mutations compose with both.


🔹 14.1 Mental model — cache, fetcher, invalidation

TanStack Query is not a fetcher. It’s a cache with a fetcher attached.

                   ┌─────────────────────────────────────┐
                   │  QueryClient (in-memory cache)      │
                   │                                     │
   useQuery ──▶    │  Map<QueryKey, QueryState>          │
                   │   ['invoices']         → [data, …]  │
                   │   ['invoice', '1042']  → [data, …]  │
   useMutation ──▶ │   ['invoice', '1043']  → [data, …]  │
                   │                                     │
                   │   ▼ on stale / refocus / interval   │
                   │   refetch via fetcher fn            │
                   └─────────────────────────────────────┘

Three primitives:

  • useQuery — declare “I want data identified by this key, fetched by this function.” Returns { data, isLoading, error, isStale, refetch }.
  • useMutation — declare “I want to perform this side effect, and tell the cache what to invalidate after.”
  • QueryClient — the cache itself. One instance per app, provided via <QueryClientProvider>.
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';

const qc = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 30_000, gcTime: 5 * 60_000, retry: 1 },
  },
});

const App = () => (
  <QueryClientProvider client={qc}>
    <Invoices />
  </QueryClientProvider>
);

const Invoices = () => {
  const { data, isPending, error } = useQuery({
    queryKey: ['invoices'],
    queryFn:  () => fetch('/api/invoices').then((r) => r.json()),
  });

  if (isPending) return <Spinner />;
  if (error)     return <ErrorBox error={error} />;
  return <InvoiceList invoices={data} />;
};

Three key options worth defaulting:

  • staleTime: 30_000 — data is “fresh” for 30 s; no background refetch in that window. Default 0 is too aggressive.
  • gcTime: 5 * 60_000 — keep cached data for 5 minutes after the last consumer unmounts. Helps tab-switching without refetch.
  • retry: 1 — one auto-retry on failure. Default 3 is too aggressive for non-idempotent endpoints.

🔹 14.2 Query keys as the contract

The cache key is the API. Two queries with the same key share the cache; two with different keys don’t.

['invoices']                       // list query
['invoice', id]                    // single-record query
['invoice', id, 'line-items']      // related sub-resource
['invoices', { status, page, q }]  // filtered/paginated list

Rules:

  • Keys are arrays. Strings work but arrays are clearer.
  • Use a noun + identifier shape. ['invoice', id] not ['get-invoice-by-id', id].
  • Include every input that affects the response. ['invoices', { status }] is different from ['invoices'].
  • Serialise objects deterministically. TanStack Query uses stable stringify under the hood; you don’t need to sort keys yourself.

Key factories for type safety:

export const invoiceKeys = {
  all:    ['invoices'] as const,
  lists:  () => [...invoiceKeys.all, 'list'] as const,
  list:   (filters: Filters) => [...invoiceKeys.lists(), filters] as const,
  details:() => [...invoiceKeys.all, 'detail'] as const,
  detail: (id: string) => [...invoiceKeys.details(), id] as const,
};

useQuery({ queryKey: invoiceKeys.list({ status: 'paid' }), queryFn: /* … */ });

Refactoring a key shape becomes a TS error in every consumer. Worth the ceremony for any real app.


🔹 14.3 Mutations and optimistic updates

const qc = useQueryClient();

const updateInvoice = useMutation({
  mutationFn: (patch: { id: string; amount: number }) =>
    fetch(`/api/invoices/${patch.id}`, {
      method: 'PATCH',
      body: JSON.stringify(patch),
      headers: { 'content-type': 'application/json' },
    }).then((r) => r.json()),

  onMutate: async (patch) => {
    // 1. Cancel any in-flight refetch for the affected key.
    await qc.cancelQueries({ queryKey: invoiceKeys.detail(patch.id) });

    // 2. Snapshot the previous value for rollback.
    const previous = qc.getQueryData<Invoice>(invoiceKeys.detail(patch.id));

    // 3. Optimistically update the cache.
    qc.setQueryData<Invoice>(invoiceKeys.detail(patch.id), (old) =>
      old ? { ...old, ...patch } : old,
    );

    return { previous };                              // ← rollback context
  },

  onError: (_err, patch, ctx) => {
    if (ctx?.previous) {
      qc.setQueryData(invoiceKeys.detail(patch.id), ctx.previous);
    }
  },

  onSettled: (_data, _err, patch) => {
    qc.invalidateQueries({ queryKey: invoiceKeys.detail(patch.id) });
  },
});

The pattern (onMutate → optimistic update + snapshot; onError → restore; onSettled → invalidate) is the canonical mutation flow. Memorise it.

Pairing with React 19.2 useOptimistic (Ch 2 §2.2): use useOptimistic for UI-state-only optimism (the row turns green); use TanStack Query’s onMutate for cache-state optimism (the data reflects the new value across the app). They compose — both layers contribute.


🔹 14.4 Prefetching, infinite queries, paginated lists

Prefetch on hover — the modern “feels instant” trick:

const qc = useQueryClient();

<Link
  to={`/invoices/${id}`}
  onMouseEnter={() => qc.prefetchQuery({
    queryKey: invoiceKeys.detail(id),
    queryFn:  () => fetchInvoice(id),
    staleTime: 10_000,
  })}
>
  {invoice.name}
</Link>

By the time the user clicks (250–500 ms later), the data is cached.

Infinite queries for “load more” lists:

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: invoiceKeys.list({}),
  queryFn: ({ pageParam }) => fetchInvoices({ cursor: pageParam }),
  initialPageParam: null as string | null,
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? null,
});

const all = data?.pages.flatMap((p) => p.items) ?? [];

Pair with @tanstack/react-virtual (Ch 32.3) for truly long lists.

Paginated lists with placeholderData:

useQuery({
  queryKey: invoiceKeys.list({ page }),
  queryFn:  () => fetchInvoices({ page }),
  placeholderData: (prev) => prev,    // keep showing previous page while next loads
});

The placeholderData: (prev) => prev trick prevents the spinner flash on page change — old data stays visible until new data arrives.


🔹 14.5 Devtools and debugging cache bugs

import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

<QueryClientProvider client={qc}>
  <App />
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

What the panel shows:

  • Every query, keyed by queryKey.
  • State: fresh / stale / fetching / inactive.
  • Last-fetched timestamp.
  • Cached data preview.
  • “Refetch” button per query.

Debugging recipe — “my data is stale and I don’t know why”

  1. Open devtools. Find the query. Is it “stale”?
  2. Look at staleTime. If it’s 0 (default), every refocus refetches — possibly what you want.
  3. Check whether two components are reading the same data with different query keys. The cache thinks they’re different queries; one’s update doesn’t invalidate the other.
  4. Check whether your mutation’s onSettled is invalidating the right key. A typo in the key means the cache stays.

Debugging recipe — “my mutation didn’t update the list”

The list is its own query (['invoices']); the mutation updates the detail (['invoice', id]). You need to invalidate the list too:

onSettled: () => {
  qc.invalidateQueries({ queryKey: invoiceKeys.all });  // matches both list and detail
},

The queryKey: invoiceKeys.all is a prefix match — invalidates anything starting with ['invoices'].


🔹 14.6 Pairing with Zustand — don’t duplicate state

A common mistake: copying server data into Zustand “for convenience.”

// ❌ wrong — duplicates the cache, drifts immediately
const useStore = create((set) => ({
  invoices: [] as Invoice[],
  setInvoices: (invoices) => set({ invoices }),
}));

// In a component:
const { data } = useQuery({ queryKey: ['invoices'], queryFn: fetchInvoices });
useEffect(() => { if (data) useStore.getState().setInvoices(data); }, [data]);

Now you have two sources of truth and a manual sync. Inevitably they diverge.

The right shape:

  • Server data lives in TanStack Query’s cache. Period.
  • Derived selections (which invoice is currently selected by the user) live in Zustand or local state — but the selection is an ID, not the data.
const useUi = create((set) => ({
  selectedInvoiceId: null as string | null,
  select: (id: string) => set({ selectedInvoiceId: id }),
}));

const SelectedInvoice = () => {
  const id = useUi((s) => s.selectedInvoiceId);
  const { data } = useQuery({
    queryKey: invoiceKeys.detail(id!),
    queryFn:  () => fetchInvoice(id!),
    enabled:  id != null,
  });
  return data ? <InvoicePane invoice={data} /> : null;
};

The UI store holds the selection; the query cache holds the data. Each owns one concern.


🪤 Common Pitfalls

  1. Copying query data into Zustand — two sources of truth.
  2. staleTime: 0 (default) — every refocus refetches; sometimes wanted, often not.
  3. Invalidating the wrong key after a mutation — list stays stale.
  4. Building queryKey arrays inline without a factory — typo invalidations.
  5. useMutation without onError rollback — optimistic updates that don’t undo.
  6. Catching the query error in try/catchuseQuery doesn’t throw; check error.
  7. Setting enabled: !!id then accessing data.something without a null check — data is undefined while disabled.

✅ Recap

  • TanStack Query is a cache, not a fetcher.
  • Query keys are the contract. Use key factories for type safety.
  • The mutation pattern is onMutate → optimistic + snapshot, onError → restore, onSettled → invalidate.
  • Prefetch on hover for “feels instant” navigation.
  • Pair with Zustand for UI state; never copy server data into a global store.

🔗 Further Reading

  • https://tanstack.com/query/latest — TanStack Query v5.x docs.
  • TKDodo (Dominik Dorfmeister) — “Practical React Query” blog series; the canonical deep dive.
  • Ch 5 §5.1 — RR v7 loaders (alternative pattern; sometimes complements Query).
  • Ch 13 — Zustand for the UI-state half.
  • Ch 32.3 — virtualisation for long lists.

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (6)
  1. 14.1 Mental model — cache, fetcher, invalidation
  2. 14.2 Query keys as the contract
  3. 14.3 Mutations and optimistic updates
  4. 14.4 Prefetching, infinite queries, paginated lists
  5. 14.5 Devtools and debugging cache bugs
  6. 14.6 Pairing with Zustand — don’t duplicate state