Skip to main content
友田 陽大
Frontend
React
TanStack Query
TypeScript
Next.js
状態管理
パフォーマンス
フロントエンド

TanStack Query v5 Practical Guide [2026 Latest] — Type-Safe Cache Design, Optimistic Updates, Next.js App Router Integration

A TanStack Query practical guide faithful to the latest official documentation (the v5.101 family). Explains 'when and how to use it' with production-quality code examples: the new Mutation callbacks (context.client), type-safe design with queryOptions, predicate invalidation, two methods of optimistic updates, and Next.js App Router prefetch / hydration.

Published
Reading time
17 min read
Author
友田 陽大
Share

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)

SettingMeaningDefaultMain use
staleTimeThe period data is considered "fresh." It doesn't re-fetch during this0Suppress excessive requests. Near-essential in SSR
gcTimeThe time until inactive cache is discarded from memory5 minAdjust 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.

  1. context (MutationFunctionContext) was added at the tail. Here are context.client (the QueryClient itself), context.meta, and context.mutationKey. That is, without calling useQueryClient() inside the callback, you can operate the cache from context.client.
  2. onMutate's return value became the independent onMutateResult argument. Previously the 3rd argument of onError(err, variables, context) was this return value, but the name was clarified to onMutateResult, and the real context moved 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 in todoKeys, and prefix invalidation works as intended like invalidateQueries({ queryKey: todoKeys.lists() }) (described later).
  • Direct string-literal key writing disappears, raising refactor resilience (ETC: Easy To Change).
  • Settings like staleTime are also consolidated on the definition side, so settings don't diverge between server and client.

About the error type: error is Error type by default. If you want to unify it across the whole app, you can globally register defaultError with the Register interface. Avoid careless unknown neglect or any casts, and narrow AxiosError and 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.

refetchTypeBehaviorWhere to use
"active" (default)Re-fetch only displayed queries immediatelyUsually this suffices
"inactive"Re-fetch only hidden queriesWant to warm another screen in the background first
"all"Re-fetch all, displayed and hiddenPre-reading to eliminate loading on the destination
"none"Stale but don't re-fetchCost 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."

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 cancelQueries first: 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: call getQueryData again inside onError and you pick up the value after it's already been optimistically updated. Only stashing the value at the start of onMutate guarantees 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)

SituationRecommended approach
Showing the update result in only one placeUI-based (5-1)
Reflecting the same update in multiple screens / componentsCache-based (5-2)
Wanting to avoid the complexity of rollbackUI-based (5-1)
Need consistency of derived data like detail / list / aggregationCache-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." Use useMutation and it invites unnecessary isPending state and mutationKey management, and it's semantically wrong too.
  • Always use an updater function: rather than passing the value directly with setQueryData(key, newData), use setQueryData(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.
  • placeholderData can't be used. If you want to avoid a fallback display on update, wrap the update with startTransition.
  • 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 factoryWrite 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 syncRe-getQueryData inside onError to roll back
Server state is Query, UI state is useState/a lightweight storeCopy server data into useState and manually sync
Unify the error type with Error or Register, narrow at the boundarySwallow 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/refetchTypeBulk-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 —

  1. The new signature using context.client writes Mutation callbacks concisely and safely.
  2. queryOptions + a query-key factory centralizes definitions and flows types to everything.
  3. predicate / refetchType controls the network surgically.
  4. Use the 2 optimistic-update methods appropriately, and ensure consistency with cancelQueries → snapshot → update → onSettled sync.
  5. 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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading