This article takes the latest official documentation (Optimistic Updates, Mutations, TypeScript, Advanced SSR) as the primary source, and delves to the point of being able to judge in practice "in which scene, how to write it."
1. Premise: why TanStack Query (server state vs. client state)
Many bugs are born from copying "data fetched from the server" into useState or Redux and trying to sync it by hand. Remote data essentially has the properties of
- you don't own it (other users or other tabs rewrite it)
- it can become old (stale) from the instant you fetch it
- it's asynchronous, with failure, retry, and cancellation happening
TanStack Query centrally manages this "server state" as a cache and automates re-fetching, deduplication, background updates, and error recovery. Conversely, client state like "modal open/close" or "a form's in-progress input value" is correctly still held in useState or a lightweight store. Don't confuse the roles — this is the starting point of design.
The difference between staleTime and gcTime (the most frequent confusion point)
| Setting | Meaning | Default | Main use |
|---|---|---|---|
staleTime | The period data is considered "fresh." It doesn't re-fetch during this | 0 | Suppress excessive requests. Near-essential in SSR |
gcTime | The time until inactive cache is discarded from memory | 5 min | Adjust memory usage and UX (instant display when returning) |
The point is "staleTime controls the number of network requests, gcTime controls the memory lifetime." The default staleTime: 0 means "re-fetch in the background on every mount or refocus." This is safe-side behavior, but on SSR or cost-sensitive screens, explicitly raise staleTime (important in the App Router below).
From here on, on the premise of understanding the basic API, the explanation is narrowed to cache design that withstands production operation.
2. [2026 Latest Spec] The new Mutation callback signature and context.client
First, the most important update that makes a difference from many existing articles. In v5's latest version, the arguments of the Mutation lifecycle callbacks were organized as follows.
onMutate: (variables, context) => onMutateResult | Promise<onMutateResult | void>
onSuccess: (data, variables, onMutateResult, context) => unknown
onError: (error, variables, onMutateResult, context) => unknown
onSettled: (data, error, variables, onMutateResult, context) => unknown
There are two points.
context(MutationFunctionContext) was added at the tail. Here arecontext.client(theQueryClientitself),context.meta, andcontext.mutationKey. That is, without callinguseQueryClient()inside the callback, you can operate the cache fromcontext.client.onMutate's return value became the independentonMutateResultargument. Previously the 3rd argument ofonError(err, variables, context)was this return value, but the name was clarified toonMutateResult, and the realcontextmoved to the 4th argument.
Does existing code break?
No. The positions of the 1st–3rd arguments of onError / onSettled are unchanged (the 3rd argument is onMutate's return value as before). As long as you don't use the added 4th argument context, existing code works as-is. In new implementations, using context.client makes the code cleaner and reduces dependencies (the useQueryClient hook call).
// 旧来の書き方(今も動く)
const queryClient = useQueryClient();
useMutation({
mutationFn: updateTodo,
onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
// 新シグネチャ:useQueryClient 不要、context.client を使う
useMutation({
mutationFn: updateTodo,
onSettled: (_data, _err, _vars, _onMutateResult, context) =>
context.client.invalidateQueries({ queryKey: ["todos"] }),
});
On the premise of this context.client, let me build the subsequent optimistic updates in the latest spec.
3. The starting point of type safety: the queryOptions helper and query-key design
What the official TypeScript guide first recommends is the queryOptions helper. It's a mechanism to consolidate a query's definition (queryKey + queryFn + options) in one place and flow types and settings to all of useQuery / prefetchQuery / getQueryData. This simultaneously solves both DRY (a single truth) and type safety.
// lib/todos/queries.ts
import { queryOptions } from "@tanstack/react-query";
import { fetchTodos, fetchTodo } from "./api";
import type { Todo, TodoFilters } from "./types";
// クエリキーファクトリ:キーの命名を一元管理し、タイポと不整合を排除する
export const todoKeys = {
all: ["todos"] as const,
lists: () => [...todoKeys.all, "list"] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, "detail"] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};
// queryOptions に定義を集約。戻り値は型情報を保持する
export const todoListOptions = (filters: TodoFilters) =>
queryOptions({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodos(filters),
staleTime: 60_000,
});
export const todoDetailOptions = (id: number) =>
queryOptions({
queryKey: todoKeys.detail(id),
queryFn: () => fetchTodo(id),
});
The call site is one line. And because getQueryData automatically knows the return type, hand-writing generics becomes unnecessary.
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { todoListOptions, todoKeys } from "@/lib/todos/queries";
function TodoList({ filters }: { filters: TodoFilters }) {
const { data } = useQuery(todoListOptions(filters)); // data: Todo[] | undefined(自動推論)
// ...
}
// キャッシュ直接読み取りも型安全
const queryClient = useQueryClient();
const cached = queryClient.getQueryData(todoListOptions(filters).queryKey);
// ^? Todo[] | undefined — ジェネリクス指定なしで型がつく
Why this works
- The query-key structure (the hierarchy
["todos", "list", filters]) is consolidated intodoKeys, and prefix invalidation works as intended likeinvalidateQueries({ queryKey: todoKeys.lists() })(described later). - Direct string-literal key writing disappears, raising refactor resilience (ETC: Easy To Change).
- Settings like
staleTimeare also consolidated on the definition side, so settings don't diverge between server and client.
About the error type:
errorisErrortype by default. If you want to unify it across the whole app, you can globally registerdefaultErrorwith theRegisterinterface. Avoid carelessunknownneglect oranycasts, and narrowAxiosErrorand the like with type narrowing.
4. Surgical cache invalidation: precise control of invalidateQueries
invalidateQueries({ queryKey: ["todos"] }) is convenient, but it bulk-stales all queries that start with ["todos"] (["todos", "list"], ["todos", "detail", 1], …) and re-fetches the active ones. An unintended mass re-fetch is a performance pitfall. Use the next 4 to invalidate "only the needed range."
4-1. Matching strategies (prefix / exact match / variable specification / predicate)
// プレフィックス一致:["todos"] で始まるすべて
queryClient.invalidateQueries({ queryKey: todoKeys.all });
// 変数まで指定:特定フィルタのリストだけ
queryClient.invalidateQueries({ queryKey: todoKeys.list({ type: "done" }) });
// 完全一致:そのキー自身のみ(子孫は対象外)
queryClient.invalidateQueries({ queryKey: todoKeys.all, exact: true });
// predicate:各クエリインスタンスを評価する最も柔軟な方法
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "todos" &&
typeof query.queryKey[1] === "object" &&
(query.queryKey[1] as { version?: number }).version! >= 10,
});
4-2. predicate that invalidates by domain logic
predicate can judge not only by the query key but also by the instance's state like query.meta. You can write invalidation that doesn't depend on the key structure, like "discard only sensitive data on logout."
// 定義側:meta でドメインの意味づけを付与
export const useSensitiveTodos = () =>
useQuery({
queryKey: todoKeys.list({ type: "sensitive" }),
queryFn: fetchSensitiveTodos,
meta: { cacheType: "sensitive" },
});
// ログアウト時:meta が sensitive のクエリだけを無効化
function handleLogout() {
queryClient.invalidateQueries({
predicate: (query) => query.meta?.cacheType === "sensitive",
});
}
4-3. Control "when to re-fetch" with refetchType
By default (refetchType: "active"), invalidateQueries re-fetches only the queries mounted now, and defers inactive ones to "when next mounted." You can make explicit whether to pre-read or suppress.
refetchType | Behavior | Where to use |
|---|---|---|
"active" (default) | Re-fetch only displayed queries immediately | Usually this suffices |
"inactive" | Re-fetch only hidden queries | Want to warm another screen in the background first |
"all" | Re-fetch all, displayed and hidden | Pre-reading to eliminate loading on the destination |
"none" | Stale but don't re-fetch | Cost first. Update in bulk on next access |
// 設定更新後、非表示のダッシュボードも裏で温めておく(遷移時のスピナーを消す)
queryClient.invalidateQueries({ queryKey: ["dashboard"], refetchType: "all" });
5. Optimistic Updates — the 2 proper approaches the official docs show
An optimistic update is a technique of "updating the UI immediately without waiting for the server response," and it dramatically improves UX. v5's official documentation presents two approaches, and which to choose is decided by "in how many places on screen you show the update result."
5-1. UI-based (recommended: minimal code if there's one display location)
A method of temporarily displaying as-is the variables (the in-flight input value) that useMutation returns, without rewriting the cache. The code is overwhelmingly less, and rollback handling is unnecessary.
function AddTodo() {
const { mutate, isPending, variables, isError } = useMutation({
mutationFn: (text: string) => createTodo(text),
onSettled: () =>
queryClient.invalidateQueries({ queryKey: todoKeys.lists() }),
});
return (
<>
<ul>
{todos.map((t) => (
<li key={t.id}>{t.text}</li>
))}
{/* 送信中は variables を半透明で仮表示。失敗時は再送ボタンを出す */}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
{isError && (
<li style={{ color: "red" }}>
{variables}{" "}
<button onClick={() => mutate(variables!)}>再試行</button>
</li>
)}
</ul>
<button onClick={() => mutate("新しいタスク")}>追加</button>
</>
);
}
If you want to reference this "in-flight value" from another component, attach a mutationKey and pick it up with useMutationState.
const pendingTexts = useMutationState<string>({
filters: { mutationKey: ["addTodo"], status: "pending" },
select: (mutation) => mutation.state.variables as string,
});
5-2. Cache-based (this one if reflecting in multiple places)
When you want to "immediately reflect in multiple places like the list, the detail, and a badge," rewrite the cache directly. Here, safe rollback becomes the key point. Written in the new signature (context.client / onMutateResult), it's like this.
// lib/todos/mutations.ts
import { useMutation } from "@tanstack/react-query";
import { updateTodo } from "./api";
import { todoKeys } from "./queries";
import type { Todo, UpdateTodoInput } from "./types";
export const useToggleTodo = () =>
useMutation({
mutationFn: (input: UpdateTodoInput) => updateTodo(input),
// ① Mutation 実行直前。context.client から QueryClient にアクセス
onMutate: async (input, context) => {
const key = todoKeys.lists();
// 1-1. 進行中の再フェッチを止める(楽観更新がサーバー応答で上書きされるのを防ぐ)
await context.client.cancelQueries({ queryKey: key });
// 1-2. ロールバック用に現在のキャッシュを退避
const previousTodos = context.client.getQueryData<Todo[]>(key);
// 1-3. キャッシュをイミュータブルに楽観更新
context.client.setQueryData<Todo[]>(key, (old) =>
(old ?? []).map((todo) =>
todo.id === input.id ? { ...todo, ...input } : todo,
),
);
// 1-4. 戻り値は onMutateResult として onError / onSettled に渡る
return { previousTodos };
},
// ② 失敗時:退避しておいたスナップショットへ確実に戻す
onError: (_err, _input, onMutateResult, context) => {
if (onMutateResult?.previousTodos) {
context.client.setQueryData(todoKeys.lists(), onMutateResult.previousTodos);
}
},
// ③ 成否に関わらず、最後はサーバーの「正」と同期する
onSettled: (_data, _err, _input, _onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
Why this order is "production quality":
- The reason for calling
cancelQueriesfirst: omit this and a re-fetch that ran right after the optimistic update overwrites the UI with old data, producing a flicker of "update → momentarily reverts → re-updates on response." - The reason for passing the snapshot via
onMutateResult: callgetQueryDataagain insideonErrorand you pick up the value after it's already been optimistically updated. Only stashing the value at the start ofonMutateguarantees a reliable rollback. - The reason for always invalidating in
onSettled: syncing with the server at the end whether success or failure prevents the accident of an optimistic-update failure remaining on screen (ensuring resilience).
Which to use? (the decision criterion)
| Situation | Recommended approach |
|---|---|
| Showing the update result in only one place | UI-based (5-1) |
| Reflecting the same update in multiple screens / components | Cache-based (5-2) |
| Wanting to avoid the complexity of rollback | UI-based (5-1) |
| Need consistency of derived data like detail / list / aggregation | Cache-based (5-2) |
6. Direct cache update without an API: setQueryData
For a "pure client-side display change" that doesn't change server state — reordering, temporary editing inside a modal, etc. — using useMutation is overkill. Directly rewriting the cache with setQueryData is correct.
import { arrayMove } from "@dnd-kit/sortable";
import type { DragEndEvent } from "@dnd-kit/core";
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
// updater 関数を渡し、必ずイミュータブルに新しい配列を返す
queryClient.setQueryData<Todo[]>(todoKeys.lists(), (old) => {
if (!old) return old;
const from = old.findIndex((t) => t.id === active.id);
const to = old.findIndex((t) => t.id === over.id);
return arrayMove(old, from, to); // 非破壊的に並び替え
});
}
The principles to grasp:
- The usage distinction from
useMutation: this is a change of display state, not a server "update." UseuseMutationand it invites unnecessaryisPendingstate andmutationKeymanagement, and it's semantically wrong too. - Always use an updater function: rather than passing the value directly with
setQueryData(key, newData), usesetQueryData(key, (old) => ...)to avoid a race condition (a collision with another update just before). - Immutably: like React's state update, return a new reference without destroying
old. With this, TanStack Query detects the change and re-renders only the necessary components.
7. Next.js App Router integration: server prefetch + hydration
In an App Router environment, "prefetch in a Server Component → hand off to the client with HydrationBoundary" is the official standard. The initial display is fast with SSR, and afterward all of TanStack Query's features (re-fetch, optimistic updates) are usable as the client's cache.
7-1. getQueryClient that isolates per request
On the server, create a new QueryClient per request (to prevent data mixing between users), and on the browser reuse a singleton. Use the officially exported isServer for the judgment.
// app/get-query-client.ts
import { isServer, QueryClient } from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
// ハイドレーション直後の即時再フェッチを防ぐ。SSR では実質必須
queries: { staleTime: 60 * 1000 },
},
});
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) return makeQueryClient(); // リクエストごとに新規生成
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient; // ブラウザでは使い回す
}
7-2. Prefetch in a Server Component → dehydrate
The point is being able to pass queryOptions (Section 3) as-is. The server's prefetch and the client's useQuery can share the same definition, and double management of the key and queryFn disappears.
// app/todos/page.tsx(Server Component)
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/app/get-query-client";
import { todoListOptions } from "@/lib/todos/queries";
import { TodoList } from "./todo-list";
export default async function TodosPage() {
const queryClient = getQueryClient();
// 同じ queryOptions を使い回す(DRY)
await queryClient.prefetchQuery(todoListOptions({ type: "done" }));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
);
}
// app/todos/todo-list.tsx(Client Component)
"use client";
import { useQuery } from "@tanstack/react-query";
import { todoListOptions } from "@/lib/todos/queries";
export function TodoList() {
// プリフェッチ済みのキャッシュから即座に描画される
const { data } = useQuery(todoListOptions({ type: "done" }));
return (
<ul>
{data?.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
);
}
7-3. Streaming: flow without await
If you want to flow HTML without waiting for all prefetches to complete, configure shouldDehydrateQuery to dehydrate pending-state queries too, and call prefetchQuery without await. It streams to the client as soon as the data resolves.
// get-query-client.ts の defaultOptions に追加
import { defaultShouldDehydrateQuery } from "@tanstack/react-query";
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === "pending",
}
Caution: don't render the same prefetched data in both the Server Component and the Client Component (a breeding ground for sync drift). Make the Server Component "the data-fetching layer" and lean the display to the client, and it won't break.
8. Integration with Suspense: useSuspenseQuery
If you want to externalize the loading/error branching to <Suspense> and an Error Boundary, use useSuspenseQuery. The biggest benefit is that data is always defined (Todo[], not including undefined), the if (isPending) branch disappears, and the code becomes linear.
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { todoListOptions } from "@/lib/todos/queries";
function TodoList({ filters }: { filters: TodoFilters }) {
// data は Todo[](undefined ではない)。isPending / error の分岐は不要
const { data } = useSuspenseQuery(todoListOptions(filters));
return (
<ul>
{data.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
);
}
The crux:
- Loading is handled by the parent's
<Suspense fallback={...}>, and errors by the Error Boundary. - The default error throwing is "throw only when there's no other displayable data" (
throwOnError: (e, q) => typeof q.state.data === "undefined"). If there's old cache, it shows that while retrying in the background. placeholderDatacan't be used. If you want to avoid a fallback display on update, wrap the update withstartTransition.- To reset the Error Boundary and retry, use
QueryErrorResetBoundary/useQueryErrorResetBoundary.
9. Best practices & anti-patterns for production operation
| What to do (Do) | What to avoid (Don't) |
|---|---|
Centralize definitions with queryOptions + a query-key factory | Write keys as string literals and scatter them everywhere |
In SSR/App Router, make staleTime explicit (e.g. 60s) | Leave staleTime: 0 and double-fetch right after hydration |
Optimistic update is cancelQueries → snapshot → update → onSettled sync | Re-getQueryData inside onError to roll back |
Server state is Query, UI state is useState/a lightweight store | Copy server data into useState and manually sync |
Unify the error type with Error or Register, narrow at the boundary | Swallow with catch (e: any) or an as cast |
A pure display change is setQueryData(updater) | Use useMutation for a display-order change |
Limit invalidateQueries with exact/predicate/refetchType | Bulk-invalidate by a parent key and re-fetch unrelated queries |
From the observability standpoint, put in the React Query Devtools during development, and the cache state, stale judgment, and re-fetch firing are visualized, letting you detect the above accidents preemptively.
10. FAQ (frequently asked questions)
Q. What's the difference between staleTime and gcTime?
A. staleTime is "the period data is considered fresh (= suppress re-fetch)," and gcTime is "the period inactive cache is held in memory." The former controls the number of network requests, the latter the memory lifetime.
Q. How to distinguish invalidateQueries, setQueryData, and refetch?
A. If you want to re-sync with the server, invalidateQueries (stale + re-fetch); if you rewrite the cache directly without calling the server, setQueryData; if you re-fetch a specific query right now, refetch.
Q. Can TanStack Query be a substitute for Redux / Zustand?
A. For "server state," it's a substitute (rather, optimal). On the other hand, "client state" like modal open/close or a form's in-progress value is still suited to useState or a lightweight store. The two don't conflict and divide roles.
Q. Is TanStack Query needed even though Next.js has Server Components? A. The initial display is often enough with Server Components, but if you need client-side re-fetch, optimistic updates, polling, infinite scroll, or cache sharing, TanStack Query is effective. The two coexist cleanly with prefetch + hydration.
Q. For optimistic updates, which to choose, UI-based or cache-based?
A. If there's one place to show the update result, UI-based (variables) is the minimal cost. If reflecting in multiple places simultaneously, cache-based (onMutate + setQueryData).
Q. Is there a v6 for the React version? A. As of June 2026, v5 is the latest for the React version. "v6" refers to the Svelte adapter, and the core shares v5. This article's code conforms to the v5 latest spec.
Summary: the cache is "the app's current state"
The key to mastering TanStack Query v5 is to actively design the cache not as "a place to put fetched data" but as "a state store that is the app's truth." Looking back at this article's pillars —
- The new signature using
context.clientwrites Mutation callbacks concisely and safely. queryOptions+ a query-key factory centralizes definitions and flows types to everything.predicate/refetchTypecontrols the network surgically.- Use the 2 optimistic-update methods appropriately, and ensure consistency with
cancelQueries→ snapshot → update →onSettledsync. - In App Router, prefetch + hydration, and as needed, Suspense to linearize the UI.
These aren't mere superficial techniques but "design choices" that simultaneously raise readability, type safety, resilience, and maintainability. Apply them correctly and both the user experience (responsiveness) and the developer experience (resistance to breakage) are lifted.
In an actual product, it becomes production quality only once you include "server-side idempotency," "retry strategy," and "monitoring / alerts" here. If you need front-end design or review that withstands such production operation, or improvement of existing code, feel free to consult me. The case below introduces the process of designing and implementing a B2B SaaS that supports an industry's core operations, emphasizing type safety, resilience, and maintainability.