コンテンツへスキップ

React Query で即座にUI反映する楽観的更新 - CRUD全パターン実装例とキャッシュ戦略

React Query で即座にUI反映する楽観的更新 - CRUD全パターン実装例とキャッシュ戦略 のアイキャッチ
Contents

    前回の記事で、React Query(TanStack Query)を使ったオフラインファースト設計の基礎概念を紹介しました。

    今回は、その実装の細部を深掘りします。「楽観的更新の概念はわかるけど、実際どう書くの?」という疑問に対して、Create(追加)・Update(更新)・Delete(削除)・ネストされたオブジェクト更新の4つの具体的なパターンで答えます。

    オフラインの環境下でも、ユーザーに「即座に反映される」体験を提供するための、本番環境で実践している実装パターンです。

    sequenceDiagram
        participant U as User
        participant C as Component
        participant Q as QueryClient
        participant A as API
    
        U->>C: ボタンクリック
        C->>Q: mutation.mutate()
        Q->>Q: onMutate (楽観的更新)
        Q-->>C: UIが即座に反映
        Q->>A: APIリクエスト
    
        alt 成功
            A-->>Q: レスポンス
            Q->>Q: onSettled (再検証)
        else 失敗
            A-->>Q: エラー
            Q->>Q: onError (ロールバック)
            Q-->>C: 元の状態に復元
        end
    

    TL;DR

    • 楽観的更新の4パターンとして、配列追加・配列要素更新・配列要素削除・オブジェクトプロパティ更新のonMutate実装例を示す
    • Context型安全性のため、ロールバック用スナップショット管理をTypeScriptで型安全にする
    • TTLプリセットとして、数秒〜無限まで用途別のstaleTime設定テンプレートを用意する
    • キャッシュバージョニングは、APIスキーマ変更時の古いキャッシュ自動クリーンアップ戦略を担う

    この設計を実装した筋トレ記録アプリ Attain を公開中。 電波の悪いジムでも快適に使えます。 👉 アプリを見る


    前提: QueryClientの設定

    オフラインファーストの基本設定は前回の記事で解説しました。ここでは追加のTTLプリセットを紹介します。

    apps/mobile/src/lib/query-client.ts
    export const TTL = {
    /** 30秒 - リアルタイムデータ(進行中のワークアウト) */
    REALTIME: 30 * 1000,
    /** 5分 - デフォルト */
    SHORT: ONE_MINUTE * 5,
    /** 10分 - 安定したデータ(ユーザー設定) */
    MEDIUM: ONE_MINUTE * 10,
    /** 1時間 - 変更が少ないデータ */
    LONG: ONE_HOUR,
    /** 無限 - 不変のマスターデータ(エクササイズカタログ) */
    STATIC: Infinity,
    } as const;

    使用例:

    // エクササイズ一覧(不変データ)
    export const useExercises = () => {
    return useQuery({
    queryKey: ['exercises'],
    queryFn: fetchExercises,
    staleTime: TTL.STATIC, // 永久にキャッシュ
    });
    };

    パターン1: 配列へのデータ追加(Create)- 一時IDで即座にUI反映

    セットを追加する例です。一時IDを発行してUIを即座に更新し、APIレスポンス後に正式なデータで同期します。

    apps/mobile/src/hooks/useWorkouts.ts
    export const useAddSet = (sessionId: string) => {
    const queryClient = useQueryClient();
    return useMutation<
    SetResponse,
    Error,
    CreateWorkoutSet,
    { previousData: WorkoutResponse | undefined } // Context型
    >({
    mutationFn: async (data: CreateWorkoutSet) => {
    const res = await apiFetch(`/api/workouts/${sessionId}/sets`, {
    method: 'POST',
    body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error('Failed to add set');
    return res.json();
    },
    // 1. 楽観的更新: APIリクエスト前に実行
    onMutate: async (newSet) => {
    // 進行中のクエリをキャンセル(競合を防ぐ)
    await queryClient.cancelQueries({ queryKey: ['workout', sessionId] });
    // 現在のキャッシュをスナップショット
    const previousData = queryClient.getQueryData<WorkoutResponse>([
    'workout',
    sessionId,
    ]);
    // キャッシュを楽観的に更新
    if (previousData) {
    const optimisticSet: WorkoutSetWithExercise = {
    id: `temp-${Date.now()}`, // 一時ID(後でサーバーのIDに置換)
    sessionId,
    exerciseId: newSet.exerciseId,
    weightKg: newSet.weightKg,
    reps: newSet.reps,
    rir: newSet.rir ?? null,
    isWarmup: newSet.isWarmup ?? false,
    isFailed: newSet.isFailed ?? false,
    isPr: false,
    completedAt: new Date().toISOString(),
    // 既存データからExercise情報を取得
    exerciseNameKey:
    previousData.sessionExercises.find(
    (e) => e.exerciseId === newSet.exerciseId,
    )?.exerciseNameKey ?? '',
    exerciseEquipment: 'barbell',
    exerciseCategory: 'chest',
    };
    queryClient.setQueryData<WorkoutResponse>(['workout', sessionId], {
    ...previousData,
    workoutSets: [...previousData.workoutSets, optimisticSet],
    });
    }
    // ロールバック用にスナップショットを返す
    return { previousData };
    },
    // 2. エラー時: ロールバック
    onError: (_err, _newSet, context) => {
    if (context?.previousData) {
    queryClient.setQueryData(['workout', sessionId], context.previousData);
    }
    },
    // 3. 完了時: サーバーの正しいデータで同期
    onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['workout', sessionId] });
    },
    });
    };

    ポイント

    1. 一時IDは temp-${Date.now()} でUI表示用に発行する
    2. Exercise情報は既存のセットまたはsessionExercisesから取得する
    3. onSettled では成功・失敗に関わらず、最終的にサーバーと同期する

    パターン2: 配列内の要素更新(Update)- map()で該当要素を置換

    既存のセットを更新する例です。該当要素のみを差し替えます。

    type UpdateSetInput = {
    setId: string;
    weightKg?: number;
    reps?: number;
    rir?: number;
    isWarmup?: boolean;
    isFailed?: boolean;
    };
    export const useUpdateSet = (sessionId: string) => {
    const queryClient = useQueryClient();
    return useMutation<
    SetResponse,
    Error,
    UpdateSetInput,
    { previousData: WorkoutResponse | undefined }
    >({
    mutationFn: async (data: UpdateSetInput) => {
    const { setId, ...updateData } = data;
    const res = await apiFetch(`/api/workouts/${sessionId}/sets/${setId}`, {
    method: 'PATCH',
    body: JSON.stringify(updateData),
    });
    if (!res.ok) throw new Error('Failed to update set');
    return res.json();
    },
    onMutate: async (updatedSet) => {
    await queryClient.cancelQueries({ queryKey: ['workout', sessionId] });
    const previousData = queryClient.getQueryData<WorkoutResponse>([
    'workout',
    sessionId,
    ]);
    if (previousData) {
    // 該当セットのみをmapで更新
    queryClient.setQueryData<WorkoutResponse>(['workout', sessionId], {
    ...previousData,
    workoutSets: previousData.workoutSets.map((set) =>
    set.id === updatedSet.setId
    ? {
    ...set,
    weightKg: updatedSet.weightKg ?? set.weightKg,
    reps: updatedSet.reps ?? set.reps,
    rir: updatedSet.rir ?? set.rir,
    isWarmup: updatedSet.isWarmup ?? set.isWarmup,
    isFailed: updatedSet.isFailed ?? set.isFailed,
    }
    : set,
    ),
    });
    }
    return { previousData };
    },
    onError: (_err, _updatedSet, context) => {
    if (context?.previousData) {
    queryClient.setQueryData(['workout', sessionId], context.previousData);
    }
    },
    onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['workout', sessionId] });
    },
    });
    };

    ポイント

    1. 部分更新は updatedSet.weightKg ?? set.weightKg で未指定フィールドの既存値を維持する
    2. map関数では該当IDのみ更新し、それ以外はそのまま返す

    パターン3: 配列から要素を削除(Delete)- filter()で対象を除外

    セットを削除する例です。filterで該当要素を除外します。

    export const useDeleteSet = (sessionId: string) => {
    const queryClient = useQueryClient();
    return useMutation<
    DeleteResponse,
    Error,
    string, // setId
    { previousData: WorkoutResponse | undefined }
    >({
    mutationFn: async (setId: string) => {
    const res = await apiFetch(`/api/workouts/${sessionId}/sets/${setId}`, {
    method: 'DELETE',
    });
    if (!res.ok) throw new Error('Failed to delete set');
    return res.json();
    },
    onMutate: async (setId) => {
    await queryClient.cancelQueries({ queryKey: ['workout', sessionId] });
    const previousData = queryClient.getQueryData<WorkoutResponse>([
    'workout',
    sessionId,
    ]);
    if (previousData) {
    // filterで該当セットを除外
    queryClient.setQueryData<WorkoutResponse>(['workout', sessionId], {
    ...previousData,
    workoutSets: previousData.workoutSets.filter(
    (set) => set.id !== setId,
    ),
    });
    }
    return { previousData };
    },
    onError: (_err, _setId, context) => {
    if (context?.previousData) {
    queryClient.setQueryData(['workout', sessionId], context.previousData);
    }
    },
    onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['workout', sessionId] });
    },
    });
    };

    ポイント

    1. filter関数では削除対象以外を残す
    2. 引数の型は string(setIdのみ)でシンプルに保つ

    パターン4: ネストされたオブジェクトプロパティの更新 - Computed propertyで動的キーを設定

    エクササイズ別のRPE(主観的運動強度)を更新する例。オブジェクトのプロパティを動的に更新します。配列ではなく、辞書型(キーバリューペア)の更新パターンです。

    type SetExerciseRpeInput = {
    exerciseId: string;
    rpe: number;
    };
    export const useSetExerciseRpe = (sessionId: string) => {
    const queryClient = useQueryClient();
    return useMutation<
    SetExerciseRpeResponse,
    Error,
    SetExerciseRpeInput,
    { previousData: WorkoutResponse | undefined }
    >({
    mutationFn: async (data) => {
    const res = await apiFetch(
    `/api/workouts/${sessionId}/exercises/${data.exerciseId}/rpe`,
    {
    method: 'PUT',
    body: JSON.stringify({ rpe: data.rpe }),
    },
    );
    if (!res.ok) throw new Error('Failed to set exercise RPE');
    return res.json();
    },
    onMutate: async (data) => {
    await queryClient.cancelQueries({ queryKey: ['workout', sessionId] });
    const previousData = queryClient.getQueryData<WorkoutResponse>([
    'workout',
    sessionId,
    ]);
    if (previousData) {
    // オブジェクトのプロパティを動的に更新
    queryClient.setQueryData<WorkoutResponse>(['workout', sessionId], {
    ...previousData,
    exerciseRpes: {
    ...previousData.exerciseRpes,
    [data.exerciseId]: data.rpe, // Computed property
    },
    });
    }
    return { previousData };
    },
    onError: (_err, _data, context) => {
    if (context?.previousData) {
    queryClient.setQueryData(['workout', sessionId], context.previousData);
    }
    },
    onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['workout', sessionId] });
    },
    });
    };

    ポイント

    1. Computed property では [data.exerciseId]: data.rpe で動的キーを設定する
    2. スプレッド演算子で既存のRPE設定を保持しつつ、該当エクササイズのみ更新する

    高度なトピック: キャッシュバージョニング戦略

    前回の記事で「将来的に対策予定」と書いたキャッシュバージョニングを、実際に実装しました。

    本番環境でスキーマ変更時の不整合を防ぐための、必須の仕組みです。

    問題: Hydration Error

    APIの型定義が変わると、古いキャッシュを読み込んだ際にパースエラーが発生します。

    TypeError: Cannot read property 'exerciseId' of undefined

    解決: バージョン付きキャッシュキー

    apps/mobile/src/lib/query-client.ts
    /**
    * Cache Version
    *
    * WHEN TO BUMP:
    * - Breaking API schema changes
    * - Query key structure changes
    * - Major React Query upgrades
    */
    export const CACHE_VERSION = 1;
    export const QUERY_CACHE_KEY = `ATTAIN_QUERY_CACHE_v${CACHE_VERSION}`;
    export const asyncStoragePersister = createAsyncStoragePersister({
    storage: AsyncStorage,
    key: QUERY_CACHE_KEY, // バージョン付きキー
    throttleTime: 3000,
    });

    古いキャッシュのクリーンアップ

    apps/mobile/src/components/QueryProvider.tsx
    async function cleanupOldCaches(): Promise<void> {
    const allKeys = await AsyncStorage.getAllKeys();
    const cacheKeys = allKeys.filter(
    (key) =>
    key.startsWith("ATTAIN_QUERY_CACHE_v") &&
    key !== QUERY_CACHE_KEY // 現在のバージョン以外
    );
    if (cacheKeys.length > 0) {
    await AsyncStorage.multiRemove(cacheKeys);
    console.log(`Cleaned up ${cacheKeys.length} old cache versions`);
    }
    }
    export const QueryProvider = ({ children }) => {
    return (
    <PersistQueryClientProvider
    client={queryClient}
    persistOptions={persistOptions}
    onSuccess={cleanupOldCaches} // 復元後にクリーンアップ
    >
    {children}
    </PersistQueryClientProvider>
    );
    };

    バージョンアップのタイミング

    変更内容バージョンアップ
    APIレスポンスのフィールド追加(optional)不要
    APIレスポンスのフィールド削除必要
    APIレスポンスの型変更(string→number等)必要
    クエリキーの構造変更必要
    React Queryのメジャーアップグレード必要

    実装ガイド: パターン早見表

    操作onMutate内の処理配列操作
    追加スプレッドで末尾に追加[...prev, newItem]
    更新mapで該当要素を差し替え.map(item => item.id === id ? {...item, ...updates} : item)
    削除filterで該当要素を除外.filter(item => item.id !== id)
    オブジェクト更新Computed propertyで上書き{ ...prev, [key]: value }

    楽観的更新は、同じパターンの繰り返しです。一度理解すれば、あらゆるCRUD操作に適用できます。


    次のステップ: これらのパターンを実装したアプリ

    この記事で紹介した楽観的更新とキャッシュバージョニング戦略は、すべて公開中の筋トレ記録アプリ Attain で実装されています。

    なぜこの設計が必要なのか

    実務で遭遇する課題:

    課題Attainでの解決方法
    低速ネットワーク(ジム)7日間のキャッシュ保持 + 楽観的更新で0msの反応
    API仕様の変更キャッシュバージョニングで自動クリーンアップ
    数百行のコード重複CRUDの4パターンで全てカバー
    UIの遅延反映onMutateで即座にState更新

    実際に動作するコードを確認

    • PWA対応 - ブラウザからインストール不要、オフラインでも動作
    • 実装パターンを実践 - 記事のコードをそのまま活用可能
    • 無料で試す - コア機能は完全無料

    👉 Attainをブラウザで試す


    質問・フィードバックは X (@paveg_) まで!

    X Facebook B! Hatena