Skip to main content

楽観的更新 — レスポンスを待たずに UI を進める

「いいね」ボタンを押した瞬間にハートが赤くなる。実際にはサーバーへのリクエストがまだ飛んでいる最中です。この、成功を前提に UI を先に進める手法を楽観的更新 (Optimistic Update) と呼びます。

うまく使えば体感速度が大きく変わります。ただし「速くなる」の中身を取り違えると、期待した効果が得られないまま複雑さだけが増えます。

この章で学ぶこと

  • 楽観的更新が何をしていて、何をしていないか
  • 実装に必要な 4 つのステップと、失敗時のロールバック
  • 使ってよい場面と、使ってはいけない場面の判断基準
  • サーバー側のバリデーションを省略できない理由
前提知識

宣言的 UI と命令的 UI の「状態が単一の真実の源」という考え方を前提にします。

この章で扱わないこと

ライブラリごとの実装の詳細は既存のガイドが扱います。

待たせない代わりに、一時的に嘘をつく

通常のフローと楽観的更新のフローを並べます。

違いは反映の順序だけです。**リクエストは同じように 1 回飛びます。**待ち時間を消しているのではなく、待ち時間をユーザーから隠しています。

失敗した場合は、隠した嘘を取り消す必要があります。

このロールバックが必須です。ロールバックを書かないと、画面上は成功しているのにサーバーには何も記録されていない、という食い違いが残ります。楽観的更新でいちばん危険なのはこの状態です。

実装の 4 ステップ

ライブラリを使わない場合も、使う場合も、やることは同じです。

  1. 進行中の再取得を止める — 裏で走っている取得が後から完了すると、楽観的に書き換えた値を古い値で上書きしてしまいます。完了した順に処理が積まれるためで、開始した順に終わる保証はありません (イベントループ)
  2. 現在の値を退避する — ロールバック先が要ります
  3. 楽観的な値を反映する — ここで画面が変わります
  4. 成否で分岐する — 失敗なら退避した値へ戻す。成功・失敗どちらの場合も、最後にサーバーから取り直して真の値へ揃える

TanStack Query はこの 4 つを 3 つのフックに割り当てています。

useMutation({
mutationFn: toggleLike,
onMutate: async (newLike) => {
// 1. 進行中の再取得を止める
await queryClient.cancelQueries({queryKey: ['likes']});
// 2. 現在の値を退避する
const previous = queryClient.getQueryData(['likes']);
// 3. 楽観的な値を反映する
queryClient.setQueryData(['likes'], newLike);
return {previous};
},
onError: (_err, _newLike, context) => {
// 4a. 失敗したら退避した値へ戻す
queryClient.setQueryData(['likes'], context?.previous);
},
onSettled: () => {
// 4b. 成否によらず取り直して真の値へ揃える
queryClient.invalidateQueries({queryKey: ['likes']});
},
});

React 19 の useOptimistic を使うと、ロールバックの記述が要らなくなります。

const [optimisticCount, setOptimisticCount] = useOptimistic(count);

startTransition(async () => {
setOptimisticCount(count + 1); // 先に反映
try {
const next = await like(); // 通信
setCount(next); // 成功したら本物の値へ
} catch (e) {
showError(e); // 失敗したら本物の値は更新しない
}
});

失敗時に本物の状態 (count) を更新しなければ、Transition の終了とともに楽観的な値が消えて元の表示に戻ります。「戻す処理を書く」のではなく「進める処理を書かない」ことでロールバックが成立する設計です。エラーの通知は別途必要なので、catch は書きます。

何が速くなり、何は速くならない

ここが判断を誤りやすい点です。

項目変わるか
体感速度良くなる (これが目的)
リクエストの回数変わらない。むしろ確認の再取得で増えることがある
サーバーの処理時間変わらない
サーバーの負荷変わらない、または微増
通信量変わらない、または微増

楽観的更新は**通信を減らす最適化ではありません。**リクエストは同じように飛び、サーバーは同じように処理します。変えているのは、その結果を待つあいだにユーザーへ何を見せるかだけです。

リクエスト回数を減らしたいなら、それは別の手法 (デバウンス、バッチ送信、キャッシュ) の仕事です。

使ってよい場面、避けるべき場面

判断は 3 つの条件で決まります。

使ってよいのは、次の 3 つがそろう場合です。

  1. 成功率が高い — 通常は成功し、失敗は例外的
  2. 失敗したときの影響が小さい — 元に戻して謝れば済む
  3. 結果をクライアント側で予測できる — サーバーの応答を見なくても、どうなるかが分かる

いいね、ブックマーク、既読フラグ、並べ替え、チェックボックスの切り替えなどが該当します。

避けるべきなのは次のような場合です。

避ける場面理由
決済・送金失敗時の心理的影響が大きい。「払えた」と思わせてはいけない
在庫の確保・座席の予約他ユーザーとの競合で失敗しうる。成功率が高いという前提が崩れる
サーバーが値を決める操作採番、集計、サーバー時刻など、クライアントには予測できない
取り消しが利かない操作送信済みメール、外部システムへの連携など

「結果を予測できるか」は見落とされがちです。たとえば「コメントを投稿する」を楽観的に扱う場合、コメント ID や投稿日時はサーバーが決めるので、クライアントは仮の値を置くしかありません。その仮の値をキーに使っていると、本物が返ってきたときに差し替えが要ります。

サーバー側のバリデーションは省略できない

楽観的更新はクライアント側の表示の話であって、データの正しさを保証する仕組みではありません。サーバー側のバリデーションを省略してよい理由にはなりません。

理由は 2 つあります。

  • クライアントの予測はあくまで予測です。同時に別の誰かが更新していれば外れます
  • そもそもクライアントから来るリクエストは改ざんできます。ブラウザの UI を通らない直接のリクエストを防げません

役割分担ははっきりしています。**クライアント側の検証は UX のため (即座にフィードバックする)、サーバー側の検証は正しさのため (不正なデータを入れない)。**楽観的更新を入れても、この分担は 1 ミリも動きません。

名前の似た別概念 — 楽観ロック

「楽観的更新」と「楽観ロック」は名前が似ていますが、別の層の別の話です。

楽観的更新 (Optimistic Update)楽観ロック (Optimistic Locking)
クライアントの UIデータベースの同時実行制御
目的体感速度を上げる同時更新による上書きを防ぐ
やること応答を待たずに画面を更新する版数を持たせ、更新時に食い違いを検出する

共通しているのは「たいていうまくいくと仮定して先に進み、駄目だったら後始末する」という発想だけです。片方を実装しても、もう片方が自動的に手に入るわけではありません。楽観ロックはロックと分離レベルで扱います。

よくある誤解

「楽観的更新を使うとサーバーへのリクエスト回数を減らせる」 — 減りません。リクエストは同じ回数飛びます。むしろ確認のための再取得を入れると増えます。改善するのは体感速度だけです。

「クライアント側で楽観的に整合性を保証するので、サーバー側のバリデーションは省略できる」 — できません。クライアントの予測は保証ではなく、リクエストは改ざんできます。サーバー側の検証は必須です。

「失敗しても再取得すれば直るので、ロールバックは書かなくてよい」 — 再取得が完了するまでの間、画面は嘘をついたままです。再取得自体が失敗する可能性もあります。失敗を検知した時点で戻すのが原則です。

「楽観的更新と楽観ロックは同じもの」 — 別概念です。層も目的も違います。

確認問題

問 1. 楽観的更新を入れると、1 回の操作あたりのサーバーリクエストは何回になりますか。

答え: 減りません。更新のリクエストは 1 回のままで、確認の再取得を入れるなら 2 回になります。

楽観的更新が変えているのは、レスポンスを待つあいだにユーザーへ何を見せるかだけです。通信の回数も、サーバーの処理内容も変わりません。

TanStack Query の標準的な書き方では onSettledinvalidateQueries を呼ぶので、更新 1 回 + 再取得 1 回でむしろ増えます。それでも採用されるのは、増える通信のコストより体感速度の改善のほうが価値が大きいと判断されるためです。

問 2. 次のうち楽観的更新に向かないものはどれですか。理由も答えてください。
A. 記事のブックマーク切り替え
B. ECサイトの「カートに入れる」(在庫が残り 1 個)
C. TODO の完了チェック
D. 通知の既読化

答え: B

3 つの条件に照らします。

成功率が高い失敗の影響が小さい結果を予測できる
A ブックマーク高い小さいできる
B カート投入 (残り 1 個)低い (他ユーザーと競合)大きい (買えると思わせる)できる
C TODO 完了高い小さいできる
D 既読化高い小さいできる

B は在庫の奪い合いが起きるので、成功を前提にできません。「カートに入りました」と表示してから「在庫がありませんでした」と取り消すのは、最初から少し待たせるより体験が悪くなります。

問 3. 楽観的更新の実装で onMutate の最初に進行中のリクエストをキャンセルするのはなぜですか。

答え: 進行中の取得が後から完了して、楽観的に書き換えた値を古い値で上書きしてしまうためです。

たとえば一覧の再取得が裏で走っている最中にユーザーが「いいね」を押したとします。

  1. 再取得が開始される (この時点のサーバーの値は「未いいね」)
  2. ユーザーが押し、楽観的に「いいね済み」へ書き換える
  3. 1 の再取得が完了し、「未いいね」でキャッシュを上書きする
  4. 画面が「未いいね」に戻る

ユーザーから見ると、押したのに元に戻る動きになります。これを防ぐため、楽観的な値を書き込む前に進行中の取得を止めます。

まとめ

  • 楽観的更新は、サーバーの応答を待たずに成功を前提として UI を先に更新する手法です
  • 失敗したら UI を元の状態にロールバックする必要があります。これを省くと画面とサーバーが食い違ったまま残ります
  • 実装は「進行中の取得を止める → 現在値を退避 → 楽観値を反映 → 成否で分岐」の 4 ステップです
  • **リクエスト回数は減りません。**改善するのは体感速度だけで、再取得を入れるとむしろ増えます
  • 成功率が高く・失敗の影響が小さく・結果を予測できる操作に使います。決済や在庫確保には向きません
  • **サーバー側のバリデーションは省略できません。**クライアントの検証は UX のため、サーバーの検証は正しさのためです
  • 楽観ロックとは名前が似ているだけの別概念です
関連リファレンス

次に読む