effidevFlutter・Cloudflareエッジ・クラウドコスト最適化
日本語

実践 TanStack Query v5:楽観的アップデート(Optimistic Updates)とオフラインキャッシュパターン

TanStack Query v5 楽観的アップデートとオフラインキャッシュのアーキテクチャ図

現代のWebアプリケーションにおいて、ユーザー体験(UX)はアプリケーションの成功を左右する重要な要素です。ユーザーがボタンをクリックしたときにローディングスピナーを表示する代わりに、UIが即座に反応すればどれほど素晴らしいでしょうか。

この記事では、TanStack Query v5(旧React Query)を活用して遅延のない体験を提供する楽観的アップデート(Optimistic Updates)の実装方法と、ネットワーク接続が切れたときに備えるオフラインキャッシュパターンについて深く掘り下げます。

1. 楽観的アップデート(Optimistic Updates)とは?

楽観的アップデートとは、サーバーの応答を待たずに、リクエストが成功すると楽観的に仮定してUIを先に更新する手法です。

TanStack Query v5では、useMutationフックのライフサイクルコールバック(onMutateonErroronSettled)を利用して、このロールバックの仕組みをエレガントに処理できます。

[!NOTE] v5では、onMutateから返されたコンテキストオブジェクトがonErroronSettledに渡され、ロールバックに使用されます。

2. TanStack Query v5で楽観的アップデートを実装する

簡単な「いいね(Like)」トグル機能を例に見てみましょう。

import { useMutation, useQueryClient } from '@tanstack/react-query';

// ポストの型定義
interface Post {
  id: string;
  title: string;
  likes: number;
}

export function useToggleLike() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (postId: string) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
    
    // onMutate: ミューテーション関数が実行される前に呼び出される
    onMutate: async (newPostId) => {
      // 1. 進行中のリフェッチをキャンセルし、楽観的アップデートが上書きされるのを防ぐ
      await queryClient.cancelQueries({ queryKey: ['posts', newPostId] });

      // 2. エラー時の復元のために、以前の状態のスナップショットを保存
      const previousPost = queryClient.getQueryData<Post>(['posts', newPostId]);

      // 3. 楽観的にキャッシュを更新
      if (previousPost) {
        queryClient.setQueryData<Post>(['posts', newPostId], {
          ...previousPost,
          likes: previousPost.likes + 1,
        });
      }

      // 4. エラー時に使用するコンテキストを返す
      return { previousPost };
    },
    
    // onError: ミューテーション失敗時、onMutateから返したコンテキストでロールバック
    onError: (err, newPostId, context) => {
      if (context?.previousPost) {
        queryClient.setQueryData(['posts', newPostId], context.previousPost);
      }
    },
    
    // onSettled: 成功でも失敗でも、完了したらキャッシュを無効化して最新化
    onSettled: (newPostId) => {
      queryClient.invalidateQueries({ queryKey: ['posts', newPostId] });
    },
  });
}

重要なポイントの分析

  1. cancelQueries: 楽観的アップデートが、サーバーから遅れて到着した古いデータで上書きされるのを防ぎます。
  2. previousPost スナップショット: キャッシュを変更する前の元の状態を保存します。
  3. コンテキストの返却: onMutateで返した値は、onErrorの第3引数として渡され、ロールバック時に活用されます。

3. オフラインファースト(Offline First)とキャッシュパターン

オフライン状態やネットワークが不安定なときに、ユーザーに真っ白な画面を見せないようにするには、適切なキャッシュ戦略が必要です。TanStack Query v5は、デフォルトで強力なオフライン対応メカニズムを提供しています。

3.1 Network Mode の設定

TanStack QueryのデフォルトのnetworkModeonlineです。これは、ネットワークに接続されていない状態では、クエリやミューテーションを実行せずに待機(Pause)状態にするという意味です。

オフラインファーストなアプリを開発する場合は、offlineFirst設定を活用してキャッシュされたデータを表示しつつ、再接続時に同期を試みるのが良いでしょう。

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      networkMode: 'offlineFirst',
      staleTime: 1000 * 60 * 5, // 5分
    },
  },
});

3.2 Persisterを利用した永続的キャッシュ

ブラウザをリロードしたり、閉じたり開いたりしたときにもデータを維持するには、Persisterプラグインが必要です。v5では、@tanstack/query-sync-storage-persisteridb-keyval(IndexedDB)ベースの非同期Persisterが主に使用されます。

IndexedDBを活用したキャッシュ永続化の例:

import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { get, set, del } from 'idb-keyval';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      cacheTime: 1000 * 60 * 60 * 24, // 24時間キャッシュを保持
    },
  },
});

const indexedDBPersister = createAsyncStoragePersister({
  storage: {
    getItem: async (key) => await get(key),
    setItem: async (key, value) => await set(key, value),
    removeItem: async (key) => await del(key),
  },
});

// アプリケーションの最上位でProviderで囲む
function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister: indexedDBPersister }}
    >
      <YourApp />
    </PersistQueryClientProvider>
  );
}

4. 実践におけるエッジケースとアーキテクチャの決定

楽観的アップデートとオフラインキャッシュを実務に適用する際には、いくつか注意すべきエッジケースがあります。

4.1 連続的な楽観的アップデートの衝突

ユーザーが「いいね」を素早く複数回クリックした場合を考えてみてください。毎回onMutateが実行されると、前の状態が中間状態に汚染される可能性があります。

解決策:

問題点 原因 解決パターン
ロールバックの失敗 onMutate実行前のキャッシュスナップショットの欠落 必ず queryClient.getQueryData の戻り値をコンテキストとして渡す
データの上書き 進行中のGETリクエストがPOSTの後に到達 queryClient.cancelQueries を onMutate の先頭に配置
リスト追加時のちらつき 一時IDとサーバーIDの不一致 サーバー応答で来たオブジェクトで一時オブジェクトを置換(mapを活用)

まとめ