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つです。

  1. onMutate 冒頭で cancelQueries(進行中の再取得による古い値の上書きを防ぐ)
  2. 書き換え前のキャッシュを context に退避
  3. 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 用と割り切ります。

確認方法

楽観的更新は「成功時に速い」だけでなく「失敗時に正しく戻る」ことが本質です。両方を確認します。

  1. 成功ケース: DevTools の Network を「Slow 3G」にして操作し、クリック直後にUIが変わることを確認する。
  2. 失敗ケース: mutationFn を一時的に throw させ(または API を 500 で返させ)、即座に変わったUIが元へ戻ることを確認する。
  3. 連打ケース: 同じ項目を素早く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が安定して作れます。まずは「いいね」やチェックボックスなど、失敗が稀で結果が自明な操作から導入するのがおすすめです。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。