../

TanStack Query

Server state in React with TanStack Query v5 (5.10x): query keys, queryOptions, caching, suspense, pagination, mutations, optimistic updates, Next.js App Router hydration and testing. Client-only state goes in Zustand; request details are in Fetch API.

Setup

bun add @tanstack/react-query
bun add -d @tanstack/react-query-devtools
bun add -d @tanstack/eslint-plugin-query  # optional
# npm i @tanstack/react-query
# pnpm add @tanstack/react-query
main.tsx
import {
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
import {
  ReactQueryDevtools,
} from "@tanstack/react-query-devtools";
import { createRoot } from "react-dom/client";
import { App } from "./app";
 
const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 30_000 },
  },
});
 
createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>,
);

Create the client once (module scope in an SPA, see SSR for servers). The devtools render only when NODE_ENV is development, so they are dropped from production builds.

DefaultValueChange when
staleTime0: cached data is stale at oncedata changes rarely; SSR (set above 0)
gcTime5 min (Infinity on the server)unused data should live longer or shorter
retry3 on the client, 0 on the server; mutations 04xx errors should not retry
retryDelayexponential, 1 s doubling, max 30 scustom backoff
refetchOnWindowFocustruenoisy dashboards, forms
refetchOnReconnecttrue
refetchOnMounttrue (if stale)
structuralSharingtrue: unchanged parts keep identityhuge payloads, non-JSON data
networkMode"online": pause when offline"always" for local-only query fns

Query keys

A key is a serializable array that identifies a cache entry. It is hashed deterministically, so object property order does not matter but array order does.

RuleExample
Include every variable the queryFn reads["todos", "list", { status, page }]
Go generic to specific["todos"] then ["todos", "detail", id]
Filters and invalidation match by prefix{ queryKey: ["todos"] } hits every todo query
exact: true matches one key onlyinvalidateQueries({ queryKey, exact: true })
Same key, same data shapedon't reuse a key for a differently selected fetch
Keep keys in one place per domaina key factory (below)
queries/todo-keys.ts
export type TodoStatus = "all" | "open" | "done";
 
export const todoKeys = {
  all: ["todos"] as const,
  lists: () => [...todoKeys.all, "list"] as const,
  list: (status: TodoStatus) =>
    [...todoKeys.lists(), { status }] as const,
  details: () => [...todoKeys.all, "detail"] as const,
  detail: (id: string) =>
    [...todoKeys.details(), id] as const,
};
Filter callHits
{ queryKey: todoKeys.all }every todo query
{ queryKey: todoKeys.lists() }all lists, any status
{ queryKey: todoKeys.list("open") }one list
{ queryKey: todoKeys.detail(id), exact: true }one detail
{ predicate: (q) => ... }custom match on the Query
{ type: "active" | "inactive" | "all" }by observer presence
{ stale: true }, { fetchStatus: "idle" }by state

Query functions and cancellation

The queryFn must return data or throw. fetch resolves on 404 and 500, so check res.ok.

use-todo.ts
import { useQuery } from "@tanstack/react-query";
 
type Todo = { id: string; title: string; done: boolean };
 
async function getTodo(id: string, signal: AbortSignal) {
  const res = await fetch(`/api/todos/${id}`, { signal });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as Todo;
}
 
export function useTodo(id: string) {
  return useQuery({
    queryKey: ["todos", "detail", id],
    queryFn: ({ signal }) => getTodo(id, signal),
  }); // UseQueryResult<Todo, Error>
}
QueryFunctionContextMeaning
queryKeythe key, typed as passed
signalAbortSignal; aborted when the query is canceled
pageParaminfinite queries only
metathe query's meta option
clientthe QueryClient
CancellationBehavior
signal read in queryFnfetch aborts when the last observer unmounts or the key changes
signal never readthe request finishes and the result is cached anyway
queryClient.cancelQueries({ queryKey })cancel now; state reverts to before the fetch
CancelledErrorthrown for canceled fetches; not shown as an error

useQuery options

OptionDefaultMeaning
queryKeyrequiredcache identity
queryFnrequired (or default fn)fetcher; skipToken disables with types intact
staleTime0ms until data counts as stale; Infinity or "static"
gcTime300_000ms an unused entry stays cached before garbage collection
enabledtruefalse stops automatic fetching (dependent queries)
selectnonederive data from the cached value; re-runs only if inputs change
placeholderDatanoneshown while pending, not cached; keepPreviousData for paging
initialDatanoneseeded into the cache as real data
retry3number, boolean or (failureCount, error) => boolean
retryDelayexponential(attempt, error) => ms
refetchOnWindowFocustruealso "always" or a function
refetchOnReconnecttrue
refetchOnMounttruerefetch stale data when a new observer mounts
refetchIntervalfalsepolling in ms, or (query) => ms | false
refetchIntervalInBackgroundfalsekeep polling in hidden tabs
throwOnErrorfalsethrow to the nearest error boundary
metanonefree-form data for global callbacks
notifyOnChangePropstrackedre-render only for the result fields you read
subscribedtruefalse reads the cache without subscribing
staleTimegcTime
Question it answerswhen to refetchwhen to forget
Clock startswhen data arriveswhen the last observer unmounts
While "fresh" or cachedserved from cache, no requestserved from cache, refetched if stale
When it expiresnext trigger refetches in backgroundentry deleted; next use is a hard load

staleTime: Infinity refetches only after invalidateQueries; staleTime: "static" ignores invalidation too (feature flags, boot config).

Status flags

status answers "do I have data?"; fetchStatus answers "is the queryFn running?".

statusfetchStatusMeaning
pendingfetchingfirst load (isLoading)
pendingidledisabled, no data yet
pendingpausedoffline before the first load
successfetchingbackground refetch (isRefetching)
successidlesettled with data
errorfetchingretrying after refetch()
erroridlefailed; error is set
FlagTrue when
isPendingno data yet
isLoadingisPending && isFetching
isFetchingany fetch is running, including background
isRefetchingisFetching && !isPending
isError / isSuccessstatus is error / success
isPlaceholderDatadata comes from placeholderData
isStalepast staleTime or invalidated
isRefetchErrorrefetch failed but old data is kept
import { useTodo } from "./use-todo";
 
function TodoTitle({ id }: { id: string }) {
  const { data, error, isPending, isFetching } = useTodo(id);
  if (isPending) return <p>Loading…</p>;
  if (error) return <p>{error.message}</p>;
  return <h2>{data.title}{isFetching && " ↻"}</h2>;
}

Narrow on isPending and error before touching data; after both checks data is defined.

queryOptions and file layout

queryOptions() returns its input unchanged but tags queryKey with the data type. Share one object between useQuery, useSuspenseQuery, queryClient.query and cache reads.

queries/todos.ts
import { queryOptions } from "@tanstack/react-query";
import { z } from "zod";
import { fetchJson } from "@/lib/fetch-json";
import { todoKeys, type TodoStatus } from "./todo-keys";
 
export const Todo = z.object({
  id: z.string(),
  title: z.string(),
  done: z.boolean(),
});
export type Todo = z.infer<typeof Todo>;
 
export const todoListOptions = (status: TodoStatus) =>
  queryOptions({
    queryKey: todoKeys.list(status),
    queryFn: ({ signal }) =>
      fetchJson(
        `/api/todos?status=${status}`,
        z.array(Todo),
        { signal },
      ),
    staleTime: 30_000,
  });
 
export const todoOptions = (id: string) =>
  queryOptions({
    queryKey: todoKeys.detail(id),
    queryFn: ({ signal }) =>
      fetchJson(`/api/todos/${id}`, Todo, { signal }),
  });
import {
  type QueryClient,
  useQuery,
} from "@tanstack/react-query";
import {
  todoListOptions,
  todoOptions,
} from "@/queries/todos";
 
declare const qc: QueryClient;
const { queryKey } = todoListOptions("open");
 
const list = qc.getQueryData(queryKey); // Todo[] | undefined
qc.setQueryData(queryKey, (old) => old?.slice(0, 10));
 
export function useOpenCount() {
  return useQuery({
    ...todoListOptions("open"),
    select: (todos) => todos.length,       // number
  });
}
await qc.query(todoOptions("1"));        // Promise<Todo>
Where query code lives
src/lib/query-client.ts    # makeQueryClient / getQueryClientfetch-json.ts      # typed fetcher, HttpErrorqueries/todo-keys.ts       # key factorytodos.ts           # queryOptions + Zod schemastodo-mutations.ts  # useAddTodo, useToggleTodousers.ts           # one file per domainfeatures/todos/todo-list.tsx  # useQuery(todoListOptions(s))todo-list.test.tsxapp/providers.tsx      # "use client" QueryClientProviderreact-query.d.ts       # Register augmentation

Export options and custom hooks, not raw useQuery calls with inline keys.

Dependent and parallel queries

import {
  skipToken,
  useQueries,
  useQuery,
} from "@tanstack/react-query";
import { todoOptions } from "@/queries/todos";
 
type User = { id: string; teamId: string | null };
declare function getUser(e: string): Promise<User>;
declare function getTeam(id: string): Promise<string[]>;
 
export function useTeam(email: string) {
  const user = useQuery({
    queryKey: ["user", email],
    queryFn: () => getUser(email),
  });
  const teamId = user.data?.teamId;
  return useQuery({                  // waits for user
    queryKey: ["team", teamId],
    queryFn: teamId ? () => getTeam(teamId) : skipToken,
  });
}
 
export function useTodosById(ids: string[]) {
  return useQueries({          // parallel, dynamic count
    queries: ids.map((id) => todoOptions(id)),
    combine: (rs) => ({
      todos: rs.flatMap((r) => (r.data ? [r.data] : [])),
      pending: rs.some((r) => r.isPending),
    }),
  });
}
PatternUse
Several useQuery calls in one componentfixed number of independent queries; they run in parallel
enabled: !!xwait for x; queryFn still needs x!
queryFn: x ? fn : skipTokensame, type-safe; refetch() won't run it
useQueries({ queries, combine })a dynamic list; combine merges results
useSuspenseQueriesparallel under suspense (avoids waterfalls)

Suspense

components/todo-view.tsx
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { todoOptions } from "@/queries/todos";
 
export function TodoView({ id }: { id: string }) {
  const { data } = useSuspenseQuery(todoOptions(id));
  return <h1>{data.title}</h1>; // data: Todo, defined
}
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { TodoView } from "@/components/todo-view";
 
export const Page = ({ id }: { id: string }) => (
  <ErrorBoundary fallback={<p>Failed to load</p>}>
    <Suspense fallback={<p>Loading…</p>}>
      <TodoView id={id} />
    </Suspense>
  </ErrorBoundary>
);
useSuspenseQueryNotes
dataalways defined; status is success
Not allowedenabled, placeholderData, throwOnError
Errorsthrown to the error boundary when there is no cached data
Two in one componentrun one after another: use useSuspenseQueries or prefetch
Key changesuspends again; wrap the change in startTransition to keep old UI
useSuspenseInfiniteQueryinfinite variant

Pagination and infinite queries

import {
  keepPreviousData,
  useQuery,
} from "@tanstack/react-query";
 
type PageOf<T> = { items: T[]; hasMore: boolean };
declare function getPage(p: number): Promise<PageOf<string>>;
 
export function usePage(page: number) {
  return useQuery({
    queryKey: ["projects", { page }],
    queryFn: () => getPage(page),
    placeholderData: keepPreviousData, // no page flash
  });
}
// isPlaceholderData: old page is on screen, disable "Next"
useInfiniteQuery option / resultMeaning
initialPageParamrequired; first pageParam
getNextPageParam(last, all, lastParam)next param, or undefined/null to stop
getPreviousPageParam(first, ...)for bi-directional lists
maxPagescap stored pages (needs both page-param fns)
data.pages, data.pageParamsarrays in fetch order
fetchNextPage(), hasNextPageload more; is there more
isFetchingNextPagethe "load more" spinner
refetchrefetches every stored page in sequence

infiniteQueryOptions() is the infinite counterpart of queryOptions():

queries/feed.ts
import { infiniteQueryOptions } from "@tanstack/react-query";
 
type Item = { id: string; text: string };
type Page = { items: Item[]; next: string | null };
declare function getFeed(
  cursor: string,
  signal: AbortSignal,
): Promise<Page>;
 
export const feedOptions = infiniteQueryOptions({
  queryKey: ["feed"],
  queryFn: ({ pageParam, signal }) =>
    getFeed(pageParam, signal),
  initialPageParam: "",        // TPageParam = string
  getNextPageParam: (last) => last.next, // null: no more
  select: (d) => d.pages.flatMap((p) => p.items), // Item[]
});

Mutations

import {
  useMutation,
  useQueryClient,
} from "@tanstack/react-query";
import { todoKeys } from "@/queries/todo-keys";
import type { Todo } from "@/queries/todos";
 
declare function postTodo(title: string): Promise<Todo>;
 
export function AddTodo() {
  const qc = useQueryClient();
  const add = useMutation({
    mutationKey: ["todos", "add"],
    mutationFn: postTodo,
    onSuccess: (todo) => {
      qc.setQueryData(todoKeys.detail(todo.id), todo);
      // return the promise: isPending lasts until refetched
      return qc.invalidateQueries({
        queryKey: todoKeys.lists(),
      });
    },
  });
  return (
    <form
      action={(fd) => add.mutate(String(fd.get("title")))}
    >
      <input name="title" disabled={add.isPending} />
      {add.error && <p>{add.error.message}</p>}
    </form>
  );
}
Option / resultNotes
mutationFn(vars, ctx)one argument for variables: pass an object for several
onMutate(vars, ctx)before the request; return value is onMutateResult
onSuccess(data, vars, onMutateResult, ctx)ctx.client is the QueryClient
onError(err, vars, onMutateResult, ctx)roll back here
onSettled(data, err, vars, onMutateResult, ctx)invalidate here
mutate(vars, { onSuccess })fire and forget; per-call callbacks run after the hook's, only if still mounted
mutateAsync(vars)returns a promise; you must catch
variables, submittedAtfor optimistic UI
reset()back to idle
scope: { id }mutations with the same id run one after another
retry0 by default
mutationOptions()typed, reusable mutation config
After a mutationUse
Response contains the new entitysetQueryData(detailKey, data)
Lists may change order or countinvalidateQueries({ queryKey: lists })
Must never show stale dataremoveQueries then refetch
UI must change before the server repliesoptimistic update

Optimistic updates via variables

Render the pending item from variables; nothing to roll back. Best when one component shows it.

features/todos/todo-list.tsx
import {
  useMutation,
  useQuery,
} from "@tanstack/react-query";
import { todoKeys } from "@/queries/todo-keys";
import { todoListOptions } from "@/queries/todos";
 
declare function postTodo(title: string): Promise<unknown>;
 
export function TodoList() {
  const { data = [] } = useQuery(todoListOptions("all"));
  const add = useMutation({
    mutationFn: postTodo,
    onSettled: (_d, _e, _v, _r, { client }) =>
      client.invalidateQueries({ queryKey: todoKeys.all }),
  });
  return (
    <ul>
      {data.map((t) => <li key={t.id}>{t.title}</li>)}
      {add.isPending && (
        <li style={{ opacity: 0.5 }}>{add.variables}</li>
      )}
    </ul>
  );
}

Elsewhere in the tree, read pending variables with useMutationState({ filters: { mutationKey, status: "pending" }, select: (m) => m.state.variables }).

Optimistic updates via the cache

Write to the cache in onMutate, snapshot for rollback, invalidate when settled. Use when several components show the data. See the optimistic toggle recipe.

SSR and Next.js App Router

Server Components prefetch into a per-request QueryClient, dehydrate it, and pass the state to HydrationBoundary; Client Components below read it with the same keys.

lib/query-client.ts
import {
  defaultShouldDehydrateQuery,
  environmentManager,
  QueryClient,
} from "@tanstack/react-query";
 
function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { staleTime: 60_000 }, // no instant refetch
      dehydrate: {
        shouldDehydrateQuery: (q) =>
          defaultShouldDehydrateQuery(q) ||
          q.state.status === "pending", // stream pending
        shouldRedactErrors: () => false, // let Next see them
      },
    },
  });
}
 
let browserClient: QueryClient | undefined;
 
export function getQueryClient() {
  if (environmentManager.isServer()) {
    return makeQueryClient(); // fresh per request
  }
  return (browserClient ??= makeQueryClient());
}
app/providers.tsx
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { getQueryClient } from "@/lib/query-client";
 
export function Providers({
  children,
}: {
  children: ReactNode;
}) {
  const client = getQueryClient(); // not useState
  return (
    <QueryClientProvider client={client}>
      {children}
    </QueryClientProvider>
  );
}
app/todos/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  noop,
} from "@tanstack/react-query";
import { getQueryClient } from "@/lib/query-client";
import { todoListOptions } from "@/queries/todos";
import { TodoList } from "@/features/todos/todo-list";
 
export default async function TodosPage() {
  const qc = getQueryClient();
  await qc.query(todoListOptions("all")).catch(noop);
  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <TodoList />
    </HydrationBoundary>
  );
}
PieceNotes
queryClient.query(opts)fetch and cache; resolves with data, throws on error
.catch(noop)prefetch only: failed queries are simply not dehydrated
queryClient.infiniteQuery(opts)same for infinite queries
fetchQuery, prefetchQuery, ensureQueryDatadeprecated in recent v5 in favor of query()
staleTime above 0otherwise the client refetches right after hydration
One client per requestnever share a module-level client on the server
cache(makeQueryClient) from Reactshare one server client across Server Components in a request
useState(() => new QueryClient())fine only with a Suspense boundary below the provider
Server-side queryFnneeds absolute URLs, or call the DB directly
@tanstack/react-query-next-experimentaluseSuspenseQuery fetches on the server with no prefetch code

Next.js caching, params and route handlers: see Next.js.

Errors and error boundaries

import {
  QueryCache,
  QueryClient,
  QueryErrorResetBoundary,
} from "@tanstack/react-query";
import { Suspense, type ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
 
declare function toast(msg: string): void;
 
export const qc = new QueryClient({
  queryCache: new QueryCache({
    // once per failed query, not per component
    onError: (err, query) => {
      if (query.state.data !== undefined) toast(err.message);
    },
  }),
});
 
export const Boundary = (p: { children: ReactNode }) => (
  <QueryErrorResetBoundary>
    {({ reset }) => (
      <ErrorBoundary
        onReset={reset}
        fallbackRender={({ resetErrorBoundary }) => (
          <button onClick={resetErrorBoundary}>Retry</button>
        )}
      >
        <Suspense fallback={<p>Loading…</p>}>
          {p.children}
        </Suspense>
      </ErrorBoundary>
    )}
  </QueryErrorResetBoundary>
);
ToolUse
error in the resultinline error UI
throwOnError: truesend errors to the nearest boundary
throwOnError: (err, query) => booleane.g. only 5xx go to the boundary
QueryErrorResetBoundary / useQueryErrorResetBoundarylet "retry" refetch queries in that boundary
QueryCache({ onError }) / MutationCache({ onError })global toasts and logging
retry: (n, err) => booleanskip retries for 4xx
isRefetchErrorbackground refetch failed; old data still shown

Typing

Let inference work: type the queryFn return value, never pass generics to useQuery by hand.

react-query.d.ts
import "@tanstack/react-query";
import type { HttpError } from "@/lib/fetch-json";
 
type Key = readonly unknown[];
 
declare module "@tanstack/react-query" {
  interface Register {
    defaultError: HttpError;   // error type everywhere
    queryMeta: { errorMessage?: string };
    mutationMeta: { invalidates?: readonly Key[] };
  }
}
TypeUse
Register.defaultErrordefault error type (else Error)
Register.queryMeta / mutationMetatyped meta
Register.queryKey / mutationKeynarrow all keys repo-wide
UseQueryResult<TData, TError>return type of a custom hook
QueryFunctionContext<TKey>type a standalone queryFn
DataTagwhat queryOptions adds to the key
InfiniteData<T, TParam>shape of infinite data
skipTokentype-safe disable (instead of enabled + !)

Testing

New QueryClient per test, retries off, real network mocked (MSW or a fetch spy).

features/todos/use-todo.test.tsx
import { expect, spyOn, test } from "bun:test";
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { todoOptions } from "@/queries/todos";
 
function wrapper({ children }: { children: ReactNode }) {
  const client = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return (
    <QueryClientProvider client={client}>
      {children}
    </QueryClientProvider>
  );
}
 
test("loads a todo", async () => {
  const todo = { id: "1", title: "Write", done: false };
  const spy = spyOn(globalThis, "fetch").mockResolvedValue(
    Response.json(todo),
  );
  const { result } = renderHook(
    () => useQuery(todoOptions("1")),
    { wrapper },
  );
  await waitFor(() =>
    expect(result.current.isSuccess).toBe(true),
  );
  expect(result.current.data).toEqual(todo);
  spy.mockRestore();
});
TipWhy
New client per testno cache leaks between tests
retry: falsefailures show at once instead of after 3 retries
Wrapper creates the clientrenderHook/render get isolated caches
DOM for bun testpreload @happy-dom/global-registrator in bunfig.toml
Test through hooks or componentsnot the QueryClient internals
queryClient.setQueryDataseed cache for component tests without network

Recipes

Query key factory with queryOptions

Keep keys and options for a domain together so every caller shares one typed source.

queries/users.ts
import { queryOptions } from "@tanstack/react-query";
 
type User = { id: string; name: string };
type Get<T> = (arg: string, s: AbortSignal) => Promise<T>;
declare const getUsers: Get<User[]>;
declare const getUser: Get<User>;
 
const all = ["users"] as const;
 
export const userQueries = {
  all,
  lists: () => [...all, "list"] as const,
  list: (search: string) =>
    queryOptions({
      queryKey: [...all, "list", { search }] as const,
      queryFn: ({ signal }) => getUsers(search, signal),
    }),
  detail: (id: string) =>
    queryOptions({
      queryKey: [...all, "detail", id] as const,
      queryFn: ({ signal }) => getUser(id, signal),
      staleTime: 5 * 60_000,
    }),
};
// qc.invalidateQueries({ queryKey: userQueries.lists() })

Optimistic todo toggle

Flip a checkbox instantly and roll back if the server rejects it.

queries/todo-mutations.ts
import { useMutation } from "@tanstack/react-query";
import { todoKeys } from "./todo-keys";
import { todoListOptions, type Todo } from "./todos";
 
declare function patchTodo(t: Pick<Todo, "id" | "done">):
  Promise<Todo>;
const listKey = todoListOptions("all").queryKey;
 
export const useToggleTodo = () =>
  useMutation({
    mutationFn: patchTodo,
    onMutate: async ({ id, done }, { client }) => {
      await client.cancelQueries({ queryKey: listKey });
      const prev = client.getQueryData(listKey); // Todo[]
      client.setQueryData(listKey, (old) =>
        old?.map((t) => (t.id === id ? { ...t, done } : t)),
      );
      return { prev };
    },
    onError: (_e, _v, res, { client }) =>
      client.setQueryData(listKey, res?.prev),
    onSettled: (_d, _e, _v, _r, { client }) =>
      client.invalidateQueries({ queryKey: todoKeys.all }),
  });

Infinite scroll list

Load the next page when a sentinel row scrolls into view (uses feedOptions from above).

import { useInfiniteQuery } from "@tanstack/react-query";
import { feedOptions } from "@/queries/feed";
 
export function Feed() {
  const q = useInfiniteQuery(feedOptions);
  // React 19: a ref callback may return its cleanup
  const watch = (el: HTMLLIElement | null) => {
    if (!el) return;
    const io = new IntersectionObserver(([e]) => {
      if (e?.isIntersecting) void q.fetchNextPage();
    });
    io.observe(el);
    return () => io.disconnect();
  };
  return (
    <ul>
      {q.data?.map((r) => <li key={r.id}>{r.text}</li>)}
      {q.hasNextPage && !q.isFetchingNextPage && (
        <li ref={watch} aria-hidden />
      )}
    </ul>
  );
}

Prefetch on hover

Start the detail request when the pointer or focus lands on a link.

import { noop, useQueryClient } from "@tanstack/react-query";
import { todoOptions } from "@/queries/todos";
 
export function TodoLink(p: { id: string; title: string }) {
  const qc = useQueryClient();
  const prefetch = () => {
    // no request while cached data is younger than 60 s
    void qc
      .query({ ...todoOptions(p.id), staleTime: 60_000 })
      .catch(noop);
  };
  return (
    <a
      href={`/todos/${p.id}`}
      onPointerEnter={prefetch}
      onFocus={prefetch}
    >
      {p.title}
    </a>
  );
}

Next.js server prefetch with streaming

Start the fetch on the server without awaiting; the pending query streams to the client and useSuspenseQuery picks it up.

app/todos/[id]/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  noop,
} from "@tanstack/react-query";
import { Suspense } from "react";
import { getQueryClient } from "@/lib/query-client";
import { todoOptions } from "@/queries/todos";
import { TodoView } from "@/components/todo-view";
 
type Props = { params: Promise<{ id: string }> };
 
export default async function Page({ params }: Props) {
  const { id } = await params;
  const qc = getQueryClient();
  void qc.query(todoOptions(id)).catch(noop); // no await
  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <Suspense fallback={<p>Loading…</p>}>
        <TodoView id={id} />
      </Suspense>
    </HydrationBoundary>
  );
}

Requires the shouldDehydrateQuery override from lib/query-client.ts above.

Typed fetcher with Zod

Validate every response at the boundary so data types are real, and throw typed HTTP errors.

lib/fetch-json.ts
import { z } from "zod";
 
export class HttpError extends Error {
  override name = "HttpError";
  constructor(readonly status: number, message: string) {
    super(message);
  }
}
 
export async function fetchJson<S extends z.ZodType>(
  url: string,
  schema: S,
  init: RequestInit = {},
): Promise<z.output<S>> {
  const headers = new Headers(init.headers);
  headers.set("accept", "application/json");
  const res = await fetch(url, { ...init, headers });
  if (!res.ok) {
    throw new HttpError(res.status, `${res.status} ${url}`);
  }
  return schema.parse(await res.json()); // throws ZodError
}
// no retry on 4xx:
// retry: (n, e) =>
//   n < 3 && !(e instanceof HttpError && e.status < 500)

References