1. 「即座に反映される気持ちよさ」の裏に潜む泥沼
現代のWebアプリケーションにおいて、ユーザーはローディングスピナーを待ってくれません。SNSの「いいね」ボタン、タスク管理の「完了」チェックボックス、アイテムの並び替えなど、タップした瞬間にUIが応答するのが当たり前の時代になりました。
この極上の操作感を生み出す魔法が 楽観的UI更新(Optimistic UI Updates) です。サーバーからのレスポンス(通常200ms〜1000ms程度)を待たずに、「リクエストは100%成功するはずだ」と仮定してクライアントのキャッシュや状態を先行更新します。
しかし、ネットワークの世界は決して平穏ではありません。トンネルに入ったスマートフォンの電波瞬断、サーバーの一時的な503エラー、レートリミット、バリデーション違反——「失敗したとき」の処理を安易に考えていると、ユーザー体験は一瞬で崩壊します。
「楽観的更新の本質は『安全な嘘をつく技術』である。嘘をつく以上、それが暴かれた瞬間の言い訳と後始末まで完全にデザインされていなければならない。」
典型的な崩壊シナリオを見てみましょう:
- 巻き戻りフリッカー: チェックを付けたタスクが、数秒後にエラーとともに「ボッ」と未完了に戻り、ユーザーは何が起きたのか理解できず混乱する。
- 入力中データの喪失: 楽観的更新が失敗してロールバックが走った際、フォーム全体の状態が初期化され、ユーザーが丹精込めて書いたテキストが消失する。
- 連続クリックによる状態逆転: 短時間に複数回トグル操作を行った結果、クライアントの復元処理と非同期リクエストの完了順序が入れ替わり、サーバーと画面の整合性が永久に狂う。
2. TanStack Queryにおける安全なロールバックの実装パターン
React / Next.js 界隈で標準となっている @tanstack/react-query(TanStack Query v5)では、useMutation のライフサイクルフックを用いて楽観的更新を構築します。
しかし、公式ドキュメントのサンプルコードをただコピーしただけでは防げない現場の落とし穴がいくつも存在します。以下に、堅牢な防御措置を組み込んだプロダクションレベルの実装スニペットを示します。
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Todo {
id: string;
title: string;
completed: boolean;
}
export function useToggleTodoMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, completed }: { id: string; completed: boolean }) => {
const res = await fetch(`/api/todos/${id}/toggle`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed }),
});
if (!res.ok) throw new Error('Failed to update todo');
return (await res.json()) as Todo;
},
// ① リクエスト送信直前: 楽観的更新を実行
onMutate: async (variables) => {
const queryKey = ['todos', variables.id];
// 【極めて重要】進行中のフェッチ(In-flight query)をキャンセルする
// これを怠ると、直前に走っていた古い再フェッチ結果が楽観的状態を上書きしてしまう
await queryClient.cancelQueries({ queryKey });
// ロールバック用のスナップショットを退避
const previousTodo = queryClient.getQueryData<Todo>(queryKey);
// キャッシュを即座に書き換え
queryClient.setQueryData<Todo>(queryKey, (old) => {
if (!old) return old;
return { ...old, completed: variables.completed };
});
// エラー発生時のコンテキストとして返却
return { previousTodo, queryKey };
},
// ② 失敗時: スナップショットへ確実にロールバック
onError: (err, variables, context) => {
if (context?.previousTodo) {
queryClient.setQueryData(context.queryKey, context.previousTodo);
}
},
// ③ 成功・失敗に関わらず最終同期
onSettled: (data, error, variables, context) => {
if (context?.queryKey) {
// サーバー側の真実(Single Source of Truth)と再同期
queryClient.invalidateQueries({ queryKey: context.queryKey });
}
},
});
}
見落とされがちな「cancelQueries」の役割
このコードで最も見逃されやすいのが await queryClient.cancelQueries({ queryKey }) の呼び出しです。
もしバックグラウンドでウィンドウフォーカスによる自動再フェッチ(refetchOnWindowFocus)が走っていた場合、あなたがキャッシュを楽観的に書き換えた直後に古いサーバーレスポンスが着弾し、UIが一瞬戻ってしまう現象(Race Condition)が発生します。先行する通信を確実にキャンセルしてからキャッシュを書き換えることが大前提です。
3. 現場で最も炎上する「新規作成(Create)の仮ID問題」
既存データの「更新(Update)」であれば、すでに確定したIDが存在するためロールバックは比較的素直です。しかし、新規アイテムの追加(Create) を楽観的に行う場合は、桁違いの複雑さに直面します。
「仮ID(Temp ID)」が引き起こすカスケード障害
新規アイテムを一覧に追加する場合、まだサーバーでDBレコードが生成されていないため、正規のIDが存在しません。開発者は往々にして、クライアント側で生成した一時的なキー(例: temp-uuid-1234)をキャッシュに注入します。
- ユーザーが「タスク作成」ボタンを押す。
- クライアントが
id: "temp-999"を振ってリスト最上部に描画(楽観的反映)。 - ここが罠: ユーザーが即座にそのタスクの「詳細を開く」「編集する」「削除する」などの追従アクションを実行する。
- アプリは
/api/todos/temp-999/deleteを叩き、サーバーは「そんなIDは存在しない」と404 Not Foundを返す。
バックエンドの生成IDに依存した設計になっていると、通信が往復するまでのわずか数百ミリ秒の隙間にユーザーが行ったすべての後続操作がエラーの連鎖を引き起こします。
解決策①: クライアント側で主キー(ULID / UUIDv7)を決定する
最もエレガントな解決策は、IDの発行責務をクライアント側に持たせるアーキテクチャです。時系列ソート可能な ULID や UUIDv7 をブラウザ側で生成し、POSTリクエストのペイロードに含めて送信します。
サーバー側は提供されたIDをそのまま主キーとして保存するため、「仮ID」という概念そのものが消滅し、後続の編集・削除もまったく同じIDで即座に実行可能になります。
解決策②: 「同期中(Pending)」状態をUIに明示する
データベースのオートインクリメントIDなど、サーバー採番を変更できないシステムでは、楽観的更新アイテムに status: 'optimistic' フラグを付与します。
UI側では半透明表示や控えめなスピナーを表示し、正規IDが確定するまでの短い間、削除や詳細編集のボタンのみを非活性(disabled)にして危険な追従操作をガードします。
4. ロールバック時のUX設計: ユーザーを裏切らない3つの鉄則
技術的にキャッシュの巻き戻し(Rollback)が完璧にできたとしても、ユーザーの心理的な体験が置き去りになっていれば優れたプロダクトとは言えません。エラー発生時には以下の3つの鉄則を守る必要があります。
鉄則1: 「勝手に元に戻して知らんぷり」をしない
ボタンを押して一度「完了」になったものが、通知もなく突然「未完了」に戻った場合、ユーザーは「自分の押し間違えか?」「誤タップしたか?」と勘違いし、もう一度ボタンを連打します。その結果、さらなるレートリミットやサーバー負荷を招きます。
ロールバックが発生した際は、必ず画面下部にトースト通知(Snackbar)を表示し、何が起きたのかを平易な言葉で説明してください。
鉄則2: 「再試行(Retry)」の主導権をユーザーに委ねる
長文の投稿やフォーム送信が失敗した際、入力内容を破棄して以前の状態に戻すのは最悪の体験です。楽観的更新に失敗したアイテムには 「送信失敗: もう一度試す」 というインラインのアクションを残し、ユーザーがワンタップで再送できる導線を残します。
鉄則3: 不可逆・破壊的操作には絶対に楽観的更新を使わない
楽観的更新は万能薬ではありません。以下の操作には 絶対に楽観的更新を適用してはいけません。
- 決済・送金処理: 金銭が絡む処理で「成功したフリ」をして後から取り消すのはユーザーの信頼を根本から破壊します。
- 不可逆な完全削除: バックアップやゴミ箱機能がないデータの物理削除。
- アクセス権限・セキュリティ設定: 権限昇格やパスワード変更など、厳密な認可検証が不可欠なトランザクション。
これらの処理では、素直にボタン内にインラインスピナーを表示し、「現在安全に処理を実行しています」と正直に伝える方が、遥かにユーザーに安心感を与えます。
5. まとめ: 「速さ」の代償として複雑性を引き受ける覚悟
楽観的UI更新は、Webアプリケーションにネイティブアプリ並みの俊敏性と快適さをもたらす強力な武器です。しかしその実態は、サーバーとの非同期通信という現実の上に薄い氷を張り、その上でスケートをするような綱渡りの技術でもあります。
「エラーが起きたら元に戻せばいい」という単純な発想から一歩踏み込み、競合キャンセル、仮IDのライフサイクル、ロールバック時のユーザー誘導までを包括的に設計できて初めて、楽観的UIは真の価値を発揮します。
もしそこまでの複雑性を引き受ける余裕がない画面であれば、「150ミリ秒待ってから上品なインラインローダーを出し、確実に成功させてからUIを切り替える」 という愚直な設計を選ぶのも、成熟したシニアエンジニアとしての賢明な判断なのです。