TanStack Query で楽観的更新を実装する
概要
「いいね」ボタンやチェックボックス、並び替えなど、ユーザー操作に対して即座に反応してほしいUIがあります。サーバーのレスポンスを待ってから画面を書き換えると数百ミリ秒の「待ち」が発生し、体感が重くなります。
楽観的更新(Optimistic Update)は、サーバーの成功を見込んで先にUIを書き換え、失敗したら元に戻す手法です。本記事では TanStack Query(旧 React Query)v5 の useMutation で、これを安全に実装する方法をハンズオン形式でまとめます。
環境
- React 18
- TanStack Query v5(
@tanstack/react-query) - TypeScript 5 系
- REST API(fetch ベース)
npm install @tanstack/react-query
QueryClientProvider でアプリをラップしている前提とします。
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export function App({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
発生する問題
楽観的更新を使わない「素直な」実装です。Todo のチェック状態を切り替える例を見ます。
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: Todo) =>
fetch(`/api/todos/${todo.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ done: !todo.done }),
}).then((res) => res.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
正しく動きますが、「リクエスト送信 → サーバー処理 → 再取得 → 反映」とネットワーク往復を待つため、回線が遅いと「押したのに変わらない」時間が生まれます。
原因
ユーザー操作の結果(チェックが入る)はほぼ確実に成功するのに、それを表示するためにネットワーク往復を待っている構造が原因です。確実な結果なら先回りして表示すればよい、というのが楽観的更新の発想です。
対応方法
useMutation の3つのライフサイクルフックを使います。
onMutate: 送信前に走る。キャッシュを先に書き換え、ロールバック用のスナップショットを返すonError: 失敗時に走る。onMutateが返した値でキャッシュを戻すonSettled: 成否とも最後に走る。invalidateQueriesでサーバーと整合を取る
ポイントは3つです。
onMutate冒頭でcancelQueries(進行中の再取得による古い値の上書きを防ぐ)- 書き換え前のキャッシュを
contextに退避 onSettledで必ずinvalidateQueries
実装例
単一リストの楽観的更新
import { useMutation, useQueryClient } from "@tanstack/react-query";
type Todo = { id: string; title: string; done: boolean };
function useToggleTodo() {
const queryClient = useQueryClient();
const queryKey = ["todos"] as const;
return useMutation({
mutationFn: (todo: Todo) =>
fetch(`/api/todos/${todo.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ done: !todo.done }),
}).then((res) => {
if (!res.ok) throw new Error("更新に失敗しました");
return res.json() as Promise<Todo>;
}),
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<Todo[]>(queryKey);
queryClient.setQueryData<Todo[]>(queryKey, (old) =>
old?.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t))
);
return { previous };
},
onError: (_err, _todo, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
}
呼び出し側はこうなります。
function TodoItem({ todo }: { todo: Todo }) {
const toggle = useToggleTodo();
return (
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggle.mutate(todo)}
/>
{todo.title}
</label>
);
}
クリックした瞬間にチェックが切り替わり、ネットワークは裏側で進みます。失敗したときだけ、チェックが元に戻ります。
複数キーへ波及する場合(リスト+詳細)
リスト(["todos"])と詳細(["todo", id])の両方のキャッシュを持つなら、両方を更新・退避・ロールバックします。
onMutate: async (todo) => {
await Promise.all([
queryClient.cancelQueries({ queryKey: ["todos"] }),
queryClient.cancelQueries({ queryKey: ["todo", todo.id] }),
]);
const previousList = queryClient.getQueryData<Todo[]>(["todos"]);
const previousDetail = queryClient.getQueryData<Todo>(["todo", todo.id]);
queryClient.setQueryData<Todo[]>(["todos"], (old) =>
old?.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t))
);
queryClient.setQueryData<Todo>(["todo", todo.id], (old) =>
old ? { ...old, done: !old.done } : old
);
return { previousList, previousDetail };
},
onError: (_err, todo, context) => {
if (context?.previousList) {
queryClient.setQueryData(["todos"], context.previousList);
}
if (context?.previousDetail) {
queryClient.setQueryData(["todo", todo.id], context.previousDetail);
}
},
onSettled: (_data, _err, todo) => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
queryClient.invalidateQueries({ queryKey: ["todo", todo.id] });
},
新規追加(一時IDの差し替え)
追加系では、サーバーが採番する ID を知らないため、仮の ID でキャッシュへ入れ、onSettled の再取得で本物に差し替えます。
onMutate: async (input: { title: string }) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData<Todo[]>(["todos"]);
const optimistic: Todo = {
id: `temp-${previous?.length ?? 0}`, // keyのための決定的な一時ID
title: input.title,
done: false,
};
queryClient.setQueryData<Todo[]>(["todos"], (old) => [
...(old ?? []),
optimistic,
]);
return { previous };
},
一時 ID を Math.random() で作ると SSR で不一致になり得るため、インデックスなど決定的な値を使うのが無難です。本物の ID は再取得で上書きされるので、一時 ID は React の key 用と割り切ります。
確認方法
楽観的更新は「成功時に速い」だけでなく「失敗時に正しく戻る」ことが本質です。両方を確認します。
- 成功ケース: DevTools の Network を「Slow 3G」にして操作し、クリック直後にUIが変わることを確認する。
- 失敗ケース:
mutationFnを一時的にthrowさせ(または API を 500 で返させ)、即座に変わったUIが元へ戻ることを確認する。 - 連打ケース: 同じ項目を素早く2回トグルし、
onSettledの再取得後にサーバー値と一致することを確認する。
React Query Devtools を入れると、キャッシュの中身が可視化できて確認が速くなります。
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
// Provider 配下に <ReactQueryDevtools initialIsOpen={false} />
注意点
cancelQueriesを省略しない。 忘れると、操作直前に始まった refetch が後から古い値でキャッシュを上書きし、「戻ったように見える」バグが出ます。onSettledのinvalidateQueriesを必ず入れる。 楽観的に書いた値は予測です。最終的にサーバーを正とすることで、他ユーザーの更新や計算項目とのズレを吸収できます。setQueryDataはイミュータブルに書く。 既存配列・オブジェクトを直接push/ 代入すると、React が差分を検知できず再描画されません。- ロールバックは
context経由で行う。onMutateの戻り値がonError/onSettledの第3引数に渡ります。クロージャ変数ではなくcontextを使うのが型・競合の両面で安全です。 - 過信は禁物。 「ほぼ確実に成功する」操作にだけ使います。在庫引き当てや残高更新のような失敗やサーバー計算が前提の操作では、ロールバックの体験がかえって混乱を招くことがあります。
まとめ
楽観的更新は onMutate(先に書く+退避)・onError(戻す)・onSettled(整合を取る)の3点セットで組み立てます。cancelQueries → context 退避 → ロールバック → invalidateQueries の型を守れば、「速く見えて、失敗しても壊れない」UIが安定して作れます。まずは「いいね」やチェックボックスなど、失敗が稀で結果が自明な操作から導入するのがおすすめです。
