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

現代のWebアプリケーションにおいて、ユーザー体験(UX)はアプリケーションの成功を左右する重要な要素です。ユーザーがボタンをクリックしたときにローディングスピナーを表示する代わりに、UIが即座に反応すればどれほど素晴らしいでしょうか。
この記事では、TanStack Query v5(旧React Query)を活用して遅延のない体験を提供する楽観的アップデート(Optimistic Updates)の実装方法と、ネットワーク接続が切れたときに備えるオフラインキャッシュパターンについて深く掘り下げます。
1. 楽観的アップデート(Optimistic Updates)とは?
楽観的アップデートとは、サーバーの応答を待たずに、リクエストが成功すると楽観的に仮定してUIを先に更新する手法です。
- メリット: ユーザーはネットワークの遅延(レイテンシ)を感じず、アプリが非常に高速だと感じます。
- デメリット: サーバーリクエストが失敗した場合、UIを以前の状態に戻す(ロールバック)という複雑さが伴います。
TanStack Query v5では、useMutationフックのライフサイクルコールバック(onMutate、onError、onSettled)を利用して、このロールバックの仕組みをエレガントに処理できます。
[!NOTE] v5では、
onMutateから返されたコンテキストオブジェクトがonErrorとonSettledに渡され、ロールバックに使用されます。
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] });
},
});
}
重要なポイントの分析
cancelQueries: 楽観的アップデートが、サーバーから遅れて到着した古いデータで上書きされるのを防ぎます。previousPostスナップショット: キャッシュを変更する前の元の状態を保存します。- コンテキストの返却:
onMutateで返した値は、onErrorの第3引数として渡され、ロールバック時に活用されます。
3. オフラインファースト(Offline First)とキャッシュパターン
オフライン状態やネットワークが不安定なときに、ユーザーに真っ白な画面を見せないようにするには、適切なキャッシュ戦略が必要です。TanStack Query v5は、デフォルトで強力なオフライン対応メカニズムを提供しています。
3.1 Network Mode の設定
TanStack QueryのデフォルトのnetworkModeはonlineです。これは、ネットワークに接続されていない状態では、クエリやミューテーションを実行せずに待機(Pause)状態にするという意味です。
online(デフォルト): ネットワークが接続されるまでリクエストを一時停止always: ネットワーク状態に関係なく、常にキャッシュの更新やフェッチを試行offlineFirst: 基本的にオフラインキャッシュを優先使用し、バックグラウンド同期を実行
オフラインファーストなアプリを開発する場合は、offlineFirst設定を活用してキャッシュされたデータを表示しつつ、再接続時に同期を試みるのが良いでしょう。
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'offlineFirst',
staleTime: 1000 * 60 * 5, // 5分
},
},
});
3.2 Persisterを利用した永続的キャッシュ
ブラウザをリロードしたり、閉じたり開いたりしたときにもデータを維持するには、Persisterプラグインが必要です。v5では、@tanstack/query-sync-storage-persisterやidb-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が実行されると、前の状態が中間状態に汚染される可能性があります。
解決策:
- UI側でデバウンス(Debouncing)を適用
- キャッシュを更新する前にローカルに更新キューを作成して管理
- 配列型のデータ(例:Todoの追加)の場合、一時的なID(
uuid)を発行し、サーバー応答後に実際のIDに置き換える
| 問題点 | 原因 | 解決パターン |
|---|---|---|
| ロールバックの失敗 | onMutate実行前のキャッシュスナップショットの欠落 | 必ず queryClient.getQueryData の戻り値をコンテキストとして渡す |
| データの上書き | 進行中のGETリクエストがPOSTの後に到達 | queryClient.cancelQueries を onMutate の先頭に配置 |
| リスト追加時のちらつき | 一時IDとサーバーIDの不一致 | サーバー応答で来たオブジェクトで一時オブジェクトを置換(mapを活用) |
まとめ
- 楽観的アップデートは、ユーザーに待たせることなく即座に反応を感じさせ、UXを飛躍的に向上させます。
onMutateでスナップショットを保存し、onErrorでロールバックするパターンを一貫して維持しましょう。- オフラインファーストのために、
offlineFirstネットワークモードとIndexedDBベースのPersisterを組み合わせることで、堅牢なオフラインWebアプリを構築できます。 - 連続的なイベントやID管理などの実務のエッジケースを事前に設計し、データの整合性を保つ必要があります。