Reactの楽観的UI更新はアプリを瞬時に感じさせます。サーバの真実とどう調整するか、失敗時の扱い方、データドリフトを防ぐ安全なパターンを学びましょう。

Reactでの楽観的UIとは、サーバが変更を確認する前に画面を更新することを指します。誰かが「いいね」を押すと、カウントがすぐに増え、その間にリクエストがバックグラウンドで実行されます。
その即時フィードバックによりアプリは高速に感じられます。遅いネットワーク上では、それが「サクサク」か「動いたのか?」の差になることが多いです。
代償はデータドリフトです:ユーザーが見るものが徐々にサーバの真の状態と合わなくなる可能性があります。ドリフトは通常、タイミングに依存する小さくてフラストレーションの要因になる不整合として現れ、再現が難しいことが多いです。
ユーザーは後で「意見を変えた」ように見えるときにドリフトに気づきます:カウンタが跳ねて元に戻る、アイテムが表示されてリフレッシュ後に消える、編集が定着したように見えたのにページを再訪すると変わっている、または別タブで異なる値が表示される、などです。
これはUIが推測をしているために起きます。サーバは異なる結果を返すかもしれません。バリデーションルール、重複排除、権限チェック、レート制限、または別のデバイスが同じレコードを変更していることなどが最終結果を変えます。もう一つのよくある原因は重なったリクエストです:古いレスポンスが最後に到着してユーザーの新しい操作を上書きしてしまうことがあります。
例:プロジェクト名を「Q1 Plan」に変更してヘッダに即時表示したとします。サーバが空白をトリムしたり、文字を拒否したり、スラッグを生成したりします。楽観的な値をサーバの最終値で置き換えないままだと、UIは次のリフレッシュまで正しく見えますが、そのときに「不思議に」変わります。
楽観的UIは常に適切というわけではありません。金銭や請求、取り返しのつかない操作、権限やロールの変更、サーバルールが複雑なワークフロー、あるいはユーザーが明示的に確認すべき副作用があるものでは慎重に(または避けて)ください。
うまく使えば、楽観的更新はアプリを即時に感じさせますが、それは調整、順序付け、失敗処理を計画している場合に限ります。
楽観的UIは次の2種類の状態を分けるときに最も効果を発揮します:
多くのドリフトはローカルの推測が確定値のように扱われたときに始まります。
単純なルール:値が現在の画面外でもビジネス的意味を持つならサーバを真のソースにしてください。画面の挙動だけに影響する(開閉、フォーカス中の入力、下書きテキストなど)ならローカルに保持します。
実践では、権限、価格、残高、在庫、計算済みやバリデート済みフィールド、他所で変わりうるもの(別タブや別ユーザー)についてはサーバ側の真実を保持します。下書き、編集中フラグ、一時フィルタ、展開行、アニメーションの切り替えなどはローカルUI状態にします。
サーバがほとんど常に受け入れるようなアクション、かつ元に戻しやすいもの(スターを付ける、単純な設定のトグルなど)は「推測しても安全」なことが多いです。
安全に推測できないフィールドの場合でも、変更を最終確定のように見せずにアプリの反応を速く感じさせることはできます。最後に確認された値を保持し、明確な保留シグナルを追加します。
例えばCRM画面で「支払い済みにする」をクリックした場合、サーバがこれを拒否するかもしれません(権限、バリデーション、既に返金済み等)。全ての派生数値を即座に書き換える代わりに、項目近くにさりげない「保存中…」ラベルを付け、合計はそのままにして、確認後にのみ合計を更新します。
良いパターンはシンプルで一貫しています:変更された項目の近くに小さな「保存中…」バッジを置く、アクションを一時的に無効化する(あるいはUndoに変える)か、楽観的な値を一時的だと示す(薄い文字や小さなスピナー)といった具合です。
サーバレスポンスが多くの場所に影響する可能性がある(合計、ソート、計算フィールド、権限)なら、すべてをパッチしようとするよりリフェッチする方が安全です。名前変更やフラグのトグルなど小さく孤立した変更なら、ローカルでパッチする方が多くの場合適しています。
役立つルール:ユーザーが変更した1つのものをパッチし、派生・集計・画面間で共有されるデータはリフェッチする。
楽観的UIは、何が確定で何がまだ推測なのかをデータモデルで追跡するときにうまく機能します。そのギャップを明示的にモデル化すれば「なぜこれが戻ったの?」という瞬間はめったに起こらなくなります。
新規作成アイテムには一時クライアントID(temp_12345 や UUID など)を割り当て、レスポンス到着時に実際のサーバIDに差し替えます。これによりリスト、選択、編集状態がきれいに調整されます。
例:ユーザーがタスクを追加します。id: "temp_a1" で即時にレンダリングし、サーバが id: 981 を返したらIDを置き換えれば、IDでキー付けされた部分はきちんと機能します。
画面レベルの読み込みフラグ1つでは粗すぎます。変化しているアイテム(あるいはフィールド)単位でステータスを追跡してください。そうすればさりげない保留UIを出し、失敗したものだけ再試行し、無関係な操作をブロックしないようにできます。
実用的なアイテムの形:
id: 実IDか一時IDstatus: pending | confirmed | failedoptimisticPatch: ローカルで変更した内容(小さく具体的に)serverValue: 最後に確認されたデータ(または confirmedAt タイムスタンプ)rollbackSnapshot: 復元可能な以前の確認済み値楽観的更新は、ユーザーが実際に変更したものだけを触るときに最も安全です(例:completed のトグル)。推測でオブジェクト全体を置き換えると、新しい編集、サーバが追加したフィールド、同時変更を簡単に消してしまいます。
良い楽観的更新は瞬時に感じられますが、最終的にサーバの言うことと一致します。楽観的変更を一時的なものとして扱い、安全に確定または元に戻すための十分な台帳を保持してください。
例:ユーザーがリストのタスクタイトルを編集する場合。タイトルはすぐに更新したいが、バリデーションエラーやサーバ側のフォーマットを扱う必要があります。
ローカル状態に即時で楽観的変更を適用し、ロールバックできるよう小さなパッチ(またはスナップショット)を保存します。
リクエストID(インクリメント番号やランダムID)を付けてリクエストを送ります。レスポンスと発火元のアクションを照合するためです。
アイテムを保留としてマークします。保留はUIをブロックする必要はありません。小さなスピナー、薄い文字、あるいは「保存中…」で十分です。重要なのはユーザーがまだ確定していないと理解することです。
成功時は一時クライアントデータをサーバのバージョンで置き換えます。サーバが空白をトリムしたり大文字小文字を調整したりタイムスタンプを更新した場合は、ローカル状態をサーバに合わせます。
失敗時はこのリクエストが変更したものだけを元に戻し、明確なローカルエラーを表示します。画面の無関係な部分を巻き戻さないでください。
ライブラリに依存しない小さな形の例:
const requestId = crypto.randomUUID();
applyOptimistic({ id, title: nextTitle, pending: requestId });
try {
const serverItem = await api.updateTask({ id, title: nextTitle, requestId });
confirmSuccess({ id, requestId, serverItem });
} catch (err) {
rollback({ id, requestId });
showError("Could not save. Your change was undone.");
}
多くのバグを防ぐ2つの詳細:アイテムが保留中の間はリクエストIDをアイテムに保存し、IDが一致する場合にのみ確定またはロールバックを行うことです。これにより古いレスポンスが新しい編集を上書きするのを止められます。
ネットワークの応答が順序入れ替わると楽観的UIは壊れます。典型的な失敗例:ユーザーがタイトルを編集し、すぐにもう一度編集したときに、最初のリクエストが最後に完了すると、その遅れて到着した応答を適用してUIが古い値に戻ってしまいます。
対策は、すべてのレスポンスを「場合によっては関連あり」と扱い、最新のユーザー意図に一致する場合にのみ適用することです。
実用的なパターンの一つはクライアントのリクエストID(カウンタ)を各楽観的変更に付け、アイテムごとに最新IDを保存することです。レスポンス到着時にIDを比較し、応答が古い場合は無視します。
バージョンチェックも役に立ちます。サーバが updatedAt や version、あるいは etag を返すなら、UIが既に持つものより新しいものだけを受け入れてください。
組み合わせ可能な他の選択肢:
例(リクエストIDガード):
let nextId = 1;
const latestByItem = new Map();
async function saveTitle(itemId, title) {
const requestId = nextId++;
latestByItem.set(itemId, requestId);
// optimistic update
setItems(prev => prev.map(i => i.id === itemId ? { ...i, title } : i));
const res = await api.updateItem(itemId, { title, requestId });
// ignore stale response
if (latestByItem.get(itemId) !== requestId) return;
// reconcile with server truth
setItems(prev => prev.map(i => i.id === itemId ? { ...i, ...res.item } : i));
}
ユーザーが素早く入力できる(ノート、タイトル、検索)場合は、保存をキャンセルするか入力が止まるまで遅延させることを検討してください。これによりサーバ負荷が下がり、遅いレスポンスが目に見えるスナップを引き起こす可能性が減ります。
失敗は楽観的UIの信頼を失わせる場所です。最悪の体験は説明なしの突然のロールバックです。
編集に対する良いデフォルトは:ユーザーの値を画面に残し、「保存されていません」とマークして、編集した箇所の近くにインラインエラーを表示することです。プロジェクト名を「Alpha」から「Q1 Launch」に変更したとき、理由がない限り「Alpha」に戻さないでください。「Q1 Launch」を表示しつつ「保存できません。名前は既に使われています」と表示し、ユーザーに修正させる方が親切です。
インラインフィードバックは失敗した正確なフィールドや行に付随します。これはトーストが出てUIが黙って戻るような「何が起きたの?」瞬間を避けます。
信頼できる手がかりには、通信中は「保存中…」、失敗時は「保存されていません」、対象行の控えめなハイライト、次に取るべきアクションを示す短いメッセージなどがあります。
再試行はほとんど常に有用です。元に戻すはアーカイブなど短時間で後悔しやすいクイックアクションに向いていますが、ユーザーが明確に新しい値を望んでいる編集では混乱を招くことがあります。
ミューテーションが失敗したとき:
権限が変わってユーザーがもう編集できないなどでロールバックが必要な場合は、その理由を説明してサーバ真実を復元します:「保存できませんでした。編集権限がありません。」
サーバレスポンスは単なる成功フラグではなくレシートとして扱ってください。リクエスト完了後に調整を行い、ユーザーの意図を維持しつつサーバが知っていることを受け入れます。
サーバがローカルの推測より多くを変更する可能性がある場合、フルリフェッチが最も安全で、理由も理解しやすいです。
ミューテーションが多くのレコードに影響する(アイテムの移動など)、権限やワークフロールールで結果が変わる可能性がある、サーバが部分的なデータを返す、他クライアントが同じビューを頻繁に更新する場合はリフェッチが良い選択です。
サーバが更新されたエンティティ(または十分なフィールド)を返すなら、マージはUXとして良い選択です:UIは安定したままサーバ真実を受け入れられます。
ドリフトは楽観的なオブジェクトでサーバ所有フィールドを上書きしてしまうことからよく発生します。カウンタ、計算値、タイムスタンプ、正規化されたフォーマットなどがそれです。
例:likedByMe=true にして likeCount を増やす楽観更新をしたとします。サーバは重複いいねを排除して違う likeCount を返すかもしれませんし、updatedAt を更新して返すかもしれません。
単純なマージ手順:
競合が起きたときの方針を事前に決めておきます。トグルには「後勝ち(Last write wins)」が良いことが多いです。フォームにはフィールドレベルのマージが適しています。
フィールドごとの「リクエスト開始以降にダーティになった」フラグ(あるいはローカルのバージョン番号)を追跡すれば、ミューテーションが始まってからユーザーが編集したフィールドについてはサーバ値を無視し、それ以外は受け入れることができます。
サーバがミューテーションを拒否したときは、驚きのロールバックより具体的で軽量なエラーを返す方が良いです。ユーザーの入力を残し、フィールドをハイライトしてメッセージを表示します。完全に取り消すのは、サーバが削除を拒否したなどそのアクションが成立し得ない場合だけにしてください。
リストは楽観的UIが気持ち良くも壊れやすい場所です。1つのアイテムの変更が並び順、合計、フィルタ、複数ページに影響することがあります。
作成時は新しいアイテムをすぐに表示しますが、保留としてマークし、一時IDを付けます。位置を安定させてジャンプしないようにします。
削除では、アイテムをすぐに隠す安全なパターンがありますが、サーバ確認が出るまで短期間「ゴースト」レコードをメモリに残しておくとUndoをサポートし、失敗を扱いやすくなります。
並べ替えは多くのアイテムに触るためややこしいです。楽観的に並べ替える場合は以前の順序を保存しておき、必要であれば復元できるようにします。
ページネーションや無限スクロールでは、楽観的挿入の位置を決めておく必要があります。フィードでは通常新しいアイテムを先頭に入れます。サーバランクのカタログではローカル挿入が誤解を与えることがあるため、目に見えるリストに挿入して保留バッジを付け、サーバレスポンスで最終的なソートキーが異なれば移動する妥協が実用的です。
一時IDが実IDになるときは安定したキーで重複除去(dedupe)してください。IDだけでマッチすると一時と確定で同じアイテムが二重に表示されることがあります。tempId→realIdのマッピングを保持し、場所を置き換えてスクロール位置や選択がリセットされないようにします。
カウントやフィルタもリスト状態です。サーバが同意すると確信がある場合のみカウントを楽観的に更新し、そうでなければ「更新中」としてマークしてレスポンス後に調整します。
ほとんどの楽観的更新バグはReact固有の問題ではなく、楽観的な変更を「新しい真実」として扱ってしまうことに起因します。
一つのフィールドだけが変わったのにオブジェクト全体や画面全体を楽観的に更新すると被害範囲が広がります。後でサーバが修正すると無関係な編集が上書きされます。
例:プロフィールフォームで設定を切り替えたときに user オブジェクト全体を置き換えると、リクエスト中にユーザーが名前を編集した場合、レスポンス到着で古い名前に戻ってしまう可能性があります。
楽観的パッチは小さく焦点を絞ってください。
別のドリフト原因は、成功やエラーの後に保留フラグをクリアし忘れることです。UIが半分読み込み中のままになり、その後のロジックがまだ楽観状態だと扱ってしまいます。
アイテムごとに保留状態を追跡しているなら、設定に使ったのと同じキーで必ずクリアしてください。一時IDはリアルIDに至るまでマッピングされていないと「ゴースト保留」アイテムを起こしがちです。
ロールバックのバグはスナップショットの保存が遅すぎるか、範囲が広すぎるときに起きます。
ユーザーが2回素早く編集すると、#2のロールバックに#1の前のスナップショットを使ってしまい、ユーザーが一度も見ていない状態にジャンプすることがあります。
対策:復元する正確なスライスをスナップショットし、そのスナップショットを特定のミューテーション試行にスコープする(多くはリクエストIDで)こと。
実際の保存処理は多段階であることが多いです。例えばステップ2(画像アップロード)が失敗した場合、ステップ1を黙って取り消さないでください。何が保存され、何がされなかったかを示し、ユーザーに次の行動を提示してください。
また、サーバが送ったものをそのまま返すとは限らないことを前提にしてください。サーバはテキストを正規化し、権限を適用し、タイムスタンプを設定し、IDを割り当て、フィールドを落とすことがあります。レスポンス(あるいはリフェッチ)から調整するようにして、楽観パッチを永遠に信頼しないでください。
楽観的UIは予測可能であるときに機能します。各楽観的変更を小さなトランザクションとして扱ってください:IDがあり、目に見える保留状態があり、明確な成功時の差し替えがあり、ユーザーを驚かせない失敗パスがあること。
出荷前に確認するチェックリスト:
素早くプロトタイプするなら、最初のバージョンは小さく保ってください:1画面、1ミューテーション、1つのリスト更新。Tools like Koder.ai (koder.ai) はUIとAPIの素案を速く作るのに役立ちますが、同じルールが当てはまります:保留と確定状態をモデル化してクライアントがサーバが受け入れたものを見失わないようにしてください。
楽観的UIは、サーバが変更を確認する前に画面を即座に更新する手法です。アプリの操作感が即時になる一方で、UIが実際に保存された状態とずれないようサーバレスポンスで必ず調整する必要があります。
データドリフトは、UIが楽観的な推測を確定値のように扱い続け、サーバ側で異なる値が保存されたり拒否されたりすることで起きます。リフレッシュ後、別タブ、またはネットワーク遅延による応答の順序ズレで現れることが多いです。
お金や請求、取り返しのつかない操作、権限変更、サーバ側ルールが複雑なワークフローでは楽観的更新は避けるか慎重に行ってください。これらは保留状態を明示して確認を待つ方が安全です。
現在の画面外でビジネス的意味を持つもの(価格、権限、計算されたフィールド、共有カウンタ等)はサーバを真実のソースと扱い、下書きやフォーカス、フィルタ等の表示上の状態はローカルに保持します。
変更が起きた場所の近くに小さく一貫したシグナルを出します。たとえば「保存中…」、薄い文字、さりげないスピナーなどです。値が一時的であることが分かるようにしつつ、ページ全体をブロックしないのが目標です。
作成時はクライアント側の一時ID(UUIDやtemp_...)を使い、成功時にサーバの実IDに置き換えます。これによりリストのキーや選択、編集状態が安定し、アイテムがちらついたり重複表示されるのを防げます。
一つのグローバルな読み込みフラグを使わず、アイテム単位(あるいはフィールド単位)で保留状態を追跡します。小さな楽観パッチとロールバック用スナップショットを保存して、その変更だけを確認または元に戻せるようにします。
各ミューテーションにリクエストIDを付け、アイテムごとに最新のIDを保持します。レスポンスが来たらIDを照合し、最新と一致する場合のみ適用します。こうすれば古い応答でUIが古い値に戻るのを防げます。
ほとんどの編集では、ユーザーの入力を画面に残し「未保存」表示を付け、編集箇所の近くにインラインエラーと再試行を出すのが良いです。本当に元に戻す必要がある場合(例:権限がなくなった等)は、理由を明示してサーバの確定値を復元してください。
変更がトータルやソート、権限、派生フィールドに影響するならリフェッチが安全です。小さく孤立した更新でサーバが更新エンティティを返す場合はマージしてUIを安定させつつサーバ側のフィールド(タイムスタンプや計算値等)を受け入れます。