代表的な面接トピック

フロントエンド面接:Optimistic UI(楽観的UI)を安全に実装するには?

フロントエンド難しい
Offer.cc 編集チーム公開日 更新日

質問

タスクエディタで保存されたタイトルを即座に表示しますが、ユーザーはいずれかのリクエストが完了する前にタイトルA、続いてタイトルBを送信する可能性があります。応答は順不同で到着する可能性があり、いずれかの保存が失敗することもあり、別のユーザーがタスクを編集することもあります。UIとサーバーの間で暗黙的な不整合が発生しないように、楽観的状態、ミューテーションの順序付け、ロールバック、調整(reconciliation)、アクセシブルなフィードバック、およびテストをどのように設計しますか?

プロンプトと適用シナリオ

タスクエディタは、送信されたタイトルを即座に表示します。ユーザーは、どちらのリクエストも完了する前にタイトルAを送信し、次にタイトルBを送信できます。2番目の応答が先に到着する可能性があり、いずれかのリクエストが失敗する可能性があり、両方が保留中にバックグラウンドの再フェッチ(refetch)が完了する可能性があり、別のユーザーが同じタスクを更新する可能性もあります。サーバーは、受け入れられた書き込みごとに正規のタスクと単調増加するリソースバージョンを返します。

クライアントの状態、リクエストの契約、順序付けポリシー、障害復旧、競合体験、アクセシビリティのフィードバック、および検証計画を設計してください。要件は、単にインターフェースを速く感じさせることだけではありません。どのような完了順序の後であっても、表示される状態は、サーバーの信頼できる確定状態に加えてユーザーの未処理の意図(intent)から説明可能でなければなりません。

この質問は、シニアフロントエンド、UIインフラストラクチャ、およびフロントエンドシステム設計の面接に適しています。公開されているフロントエンド面接資料では、楽観的更新、リクエストの競合(request races)、エラー状態、およびロールバックが明示的に取り上げられており、2025年の公開面接記録では、楽観的更新の原則とユースケースに関するフォローアップの質問が記載されています。これらの情報源はトピックの現在の関連性を裏付けていますが、特定の企業独自の質問や面接の頻度を確定するものではありません。コアとなる課題がブラウザ側の非同期状態とインタラクション設計であるため、カテゴリはfrontendです。サーバーの並行性制御はその設計への入力であり、主要な実装ターゲットではありません。

面接官が評価するポイント

最初の評価シグナルは、候補者が信頼できる確定状態(authoritative state)投機的な状態(speculative state)を分離しているかどうかです。キャッシュ内の1つのオブジェクトを置き換え、ロールバック用に古いスナップショットを保存する手法は、単一の孤立したリクエストでは機能します。しかし、後続の楽観的な処理が同じオブジェクトに依存している場合、この手法は破綻します。堅牢なモデルでは、最新の確認済みベースと保留中の意図の順序付きコレクションを保持し、その両方からレンダリングされるビューを導出します。

2番目のシグナルは、ミューテーションのセマンティクスです。「最新の応答のみを保持する」方針はクライアントの1つのレンダリングパスを保護しますが、古いリクエストがサーバーによって最後に処理されるのを防ぐことはできません。候補者は、操作を直列化(serialize)するか、統合(coalesce)するか、可換(commutative)にするか、あるいはサーバー強制の順序を割り当てるかを決定する必要があります。「タイトルをBに設定」、「1増やす」、「トグル」、「削除」、そして決済コマンドでは選択が異なります。

3番目のシグナルは、選択的リカバリです。操作Bが送信された後に操作Aが失敗した場合、Aの前のスナップショットを復元するとBが消去されてしまう可能性があります。優れた回答では、失敗した操作を削除またはマークし、有効な信頼できる応答からのみ確認済みベースを進め、残りの意図をリプレイ(再適用)します。サーバーのバージョン競合によりリプレイが安全でない場合、UIは現在のサーバー値を提示し、明示的な解決を求めます。

最後のシグナルは本番環境での規律です。保留状態やエラー状態がキーボードや支援技術で操作可能なままであること、キャンセルがサーバーのロールバックと混同されていないこと、そしてハッピーパスのみを検証するのではなく、あらゆる応答順序、失敗順序、再フェッチの競合、リトライ、および競合をテストで網羅していることです。

回答前に明確にすべき質問

  • 操作は何を意味するか? 絶対的なsetTitle("B")は以前のタイトルドラフトを上書きできますが、increment(1)は両方の操作がコミットされる必要がある場合があります。toggle()コマンドはリトライ時に曖昧になります。明示的なターゲット値の方が安全です。操作のセマンティクスによって、統合(coalescing)が有効かどうかが決まります。
  • 保存が保留中にユーザーが再度送信することは可能か? コントロールを無効化すると単純な直列化が得られますが、編集体験を損なう可能性があります。継続的な入力が必要な場合は、ローカルドラフトを個別に保持し、送信をキューイング/統合するか、バージョン管理された並列プロトコルを使用します。
  • 書き込み順序を決定するシステムはどちらか? サーバーが無条件の「後勝ち(last-arrival-wins)」の書き込みしか提供しない場合、クライアントは順序に依存する保存を直列化する必要があります。APIがベースバージョンまたはクライアントシーケンスを受け入れ、古い処理を拒否する場合、制御された並列処理が可能になります。
  • 楽観的なアクションは元に戻すことができ、リスクが低いか? 「いいね」、ラベル、ドラフトは多くの場合、楽観的なフィードバックに適しています。決済、破壊的なアクション、権限に依存する変更、不可逆的な外部効果を持つアクションは、成功を装うのではなく、確認や保留状態を必要とする場合があります。
  • バックグラウンドのソースが同じレコードを更新する可能性があるか? 再フェッチ、サブスクリプション、別のブラウザタブ、共同編集者がベースを変更する可能性があります。状態モデルはリソースバージョンを識別し、保留中の意図を新しいベースに対して安全にリプレイできるかどうかを定義する必要があります。
  • ユーザーは何を認識する必要があるか? 個々の行に保留中、リトライ、競合のインジケーターが必要かどうか、フォーカスが移動してもよいかどうか、キーストロークごとにライブリージョン(live-region)メッセージを生成せずに通知すべき保存または失敗メッセージはどれかを明確にします。

30秒の回答フレームワーク

「最新のサーバー確認済みタスクをベースとして保持し、各ローカル送信を識別された意図(intent)として表現します。UIはそのベースの上に保留中の意図をレンダリングします。タイトルの置換について、私のデフォルトはタスクごとに1つの処理中(in-flight)保存を行い、キューにあるドラフトを最新のタイトルに統合することです。リクエストID単体ではサーバーが古いリクエストを最後に適用するのを防ぐことはできません。成功時には返された正規バージョンを採用し、その意図を削除して、残りの意図をリプレイします。失敗時には失敗した意図のみを削除し、古いスナップショット全体を復元することはしません。バージョン競合が発生した場合は自動リプレイを一時停止し、両方の値を表示します。アイテムごとの保留状態とエラー状態を公開し、フォーカスを維持し、応答順序の逆転、成功と失敗の混在、再フェッチ競合、リトライ、競合、およびオフライン復旧をテストします。」

ステップごとの詳細解説

1. ライブラリを選択する前に1つの不変条件を定義する

リソースごとに、確認済みのbaseと順序付けられたpendingリストを保持します。保留中の各エントリには、不変のクライアント操作ID、意図とペイロード、送信順序、および現在のステータスが含まれます。表示される状態は純粋な射影(projection)です:

text
view = fold(base, pending in logical order, applyIntent)

不変条件は次のとおりです:レンダリングされるビューは、最新の受け入れられたサーバー状態に、適用資格がまだあるすべてのローカルの意図を加えたものに等しい。このモデルにより、遅れて到着した再フェッチや応答は、画面を上書きする指示ではなく、調整(reconciliation)への入力となります。

Reactの楽観的レデューサーパターンも同様の分離をサポートしています。Actionが保留中にベース値が変更されると、Reactは新しいベースに対してレデューサーを再実行できます。キャッシュライブラリはミューテーションのライフサイクルコールバックを管理できますが、製品の操作セマンティクスを選択するわけではありません。この不変条件は、実装にReactの状態、TanStack Query、他のクライアントキャッシュ、またはカスタムストアを使用しているかどうかにかかわらず有用です。

アドホックな同期副作用(synchronization effects)を持つserverTaskformTaskoptimisticTaskという3つの無関係なコピーを保持しないでください。入力はまだミューテーションではないため、未送信のフォームドラフトは個別に保持します。送信されたら、安定した識別子を持つ意図に変換します。

2. レイテンシからではなく、操作から順序付けを選択する

役立つ3つのポリシーがあります:

ポリシー適切なケースコストまたはリスク
リソースごとに直列化順序に依存する書き込み。APIに順序保護がない場合後続の処理は待機するが、サーバーの順序は保証可能
直列化と統合(coalesce)繰り返されるタイトルの送信など、最新の未送信値のみが重要な場合中間の送信値は意図的に破棄される
サーバー契約を伴う並列処理独立した操作や可換な操作、またはAPIがベースバージョン/クライアントシーケンスを強制する場合スループットは向上するが、調整と競合処理が明示的になる

このタイトルエディタでは、タスクIDで直列化し、キューに入っている未送信のタイトルの変更を統合します。Aが処理中でBが送信された場合、Bを楽観的に表示しますが、次のネットワーク書き込みとしてはBのみを保持します。Aが解決(settle)した後、最新の受け入れられたバージョンに対してBを送信します。TanStack Queryのドキュメントでは、ミューテーションはデフォルトで並列実行されると記載されており、直列実行のためのミューテーションスコープ(mutation scopes)が提供されています。同じポリシーはそのライブラリがなくても実装できます。

並列リクエストは、より強力なセマンティクスがある場合にのみ安全です。APIが古いベースバージョンを拒否したり、1つの編集セッションに対して単調増加するクライアントシーケンスを受け入れたり、真に可換な操作を公開したりすることが考えられます。Reactのドキュメントでも、カスタム非同期Transitionはリクエストの順序を保証しないと警告されています。高レベルの順序付きActionまたは明示的なキューが依然として必要です。ブラウザでのみ最新のリクエストIDを追跡することは、古い応答が表示中のBを上書きするのを防ぎますが、サーバーがAを最後に保存する可能性があります。また、Aを中断(abort)しても、サーバーがそれをコミットしなかった証明にはなりません。

3. 到着順序を信用せずに成功を調整(Reconcile)する

すべての応答は操作を特定し、正規のリソースとそのバージョンを返す必要があります。直列化を使用する場合は、アクティブな操作を処理し、返されたベースを採用し、操作を削除してから、キューに入れられた意図をリプレイしてビューを導出します。Aが完了する間もキューに入ったBは表示されたままであるため、画面がAに跳ね戻る(jump back)ことはありません。

制御された並列処理では、応答をその操作IDと照合し、リソースバージョンまたは確認応答(acknowledgement)を検証します。Promiseが解決されたという理由だけで、応答データを直接割り当てないでください。現在の確認済みバージョンより古い応答がベースを置き換えることはできません。応答が実際に確認した操作のみを削除します。サーバーがタイトルのトリミングなどの正規化変換を返した場合は、後続のローカルの意図をリプレイする前に、その結果を新しいベースとして使用します。

バックグラウンドの再フェッチも同じルールに従います。現在のベースがバージョン11のときに再フェッチがバージョン12を返した場合、バージョン12によってベースが進む可能性があります。その後、セマンティクスが許せば保留中の意図が再適用されます。バージョン10の再フェッチは古い証拠であり、ベースを後退させることはできません。

4. スナップショットではなく、操作単位でリカバリする

AとBが楽観的に表示された後、Aが失敗したとします。Aの前に取得したオブジェクトを復元すると、巻き添えでBが削除されてしまいます。代わりに、Aを失敗としてマークするか削除し、Bを保持して、射影を再計算します。絶対的なタイトルの割り当ての場合、Bは現在のベースに対して引き続き送信できます。順序に依存する差分の場合、元の前提が成り立たなくなったため、Bは待機するか、再計算されるか、拒否される必要があります。

失敗を実行可能な状態に分類します:

  • バリデーションまたは権限の失敗は、ユーザーが入力またはアクセス権を変更するまで確定的な失敗です。コントロールの近くに拒否された値とサーバーの理由を表示します。
  • 一時的なネットワーク障害ではリトライを表示できます。サーバーの契約によってそのリトライが安全である場合にのみ、同じ操作識別子を再利用します。そうでない場合は、元の書き込みがコミットされたかどうかをまず調整します。
  • バージョン競合は、ベースが他の場所で変更されたことを意味します。現在のサーバー値を取得または採用し、ローカルの意図と比較して、実績のあるマージルールを持つ操作のみを自動的にリプレイします。タイトルの競合については、どちらかを暗黙的に選択するのではなく、現在の値と提案された値を提示します。
  • 不明な結果(unknown outcome)は、応答が失われたとしてもリクエストがコミットされた可能性があることを意味します。「ローカルでロールバックして新規として再試行する」と、べき等(idempotent)でないアクションが重複する可能性があります。

一部のアクションは楽観的にすべきではありません。失敗した場合に取り消すのが困難な場合、認証状態を変更する場合、課金が発生する場合、または誤解を招く法的/ビジネス的状態を生じさせる場合は、即座に保留中の確認を表示し、サーバーが受け入れた後にのみ成功を確認します。

5. 投機的な状態を可視化し、アクセシブルにする

楽観的であることは、確定した状態と区別がつかないことを意味するわけではありません。影響を受けるタスクを「保存中」としてマークし、送信された値を保持し、ローカルのリトライまたは競合アクションを提供します。無関係なタスクを無効化しないでください。直列化を使用する場合は、アクティブな保存と新しいキュー内の値を区別して、ログ収集やエラーメッセージが正しい意図に紐付くようにします。

保存が成功したとき、失敗したとき、またはキャッシュが調整されたときに、キーボードフォーカスを維持します。ステータス領域は、フォーカスを移動することなく「タスクのタイトルを保存中」、「タスクのタイトルを保存しました」、「保存に失敗しました」とアナウンスできます。W3Cのstatusロールにはpoliteなライブリージョンセマンティクスがあります。メッセージが変更される前にステータスコンテナを作成し、キーストロークごとのアナウンスは避けてください。フィールド固有のエラーをそのフィールドに関連付け、色だけに依存せずに視覚的なステータスを理解できるようにします。

6. ステートマシンをテストし、ポリシーを監視する

すべてのポリシーが同じトレースを許可するかのように見せかけるのではなく、選択した順序付けポリシーをテストします。直列化されたパスでは、Bが表示されているものの、Aが解決するまでディスパッチされないことをアサートします。次に、Aの成功または失敗に続くBの成功または失敗、応答喪失に続く調整、およびサーバーによる変換をカバーします。サポートされている並列パスでは、制御可能なトランスポートとサーバースタブを使用して、A→BおよびB→Aの処理順序と応答順序を強制的に再現します。各イベント後に、レンダリングされた射影、キューに入った操作、確認済みバージョン、および最終的なサーバー値をアサートします。

次に、ミューテーションが保留中の間に新しい再フェッチと古い再フェッチ、共同編集者の競合、オフラインからオンラインへの復旧、コンポーネントのアンマウントと再マウント、繰り返しの送信、サーバーがコミットした後のキャンセルを注入します。キーボードフォーカス、ステータスのアナウンス、リトライラベル、およびエラーが正しいタスクと操作に属していることを確認します。

本番環境のテレメトリでは、体感レイテンシと正確性を分離する必要があります:楽観的レンダリングレイテンシ、確認レイテンシ、失敗およびロールバック率、競合率、キュー待機時間、統合された操作数、リトライ回数、および調整の不一致。頻繁に予期しない値へと自己修正されるような高速なインターフェースは、プロダクトの契約を果たしていません。

質の高い模範解答

「まず、送信されたすべてのタイトルを保存する必要があるのか、それともユーザーの最新の意図のみが重要なのかを質問します。ここでは最新のタイトルが重要であり、APIはリソースバージョンを返し、1つのタスクに対して並列書き込みを行う必要はありません。したがって、継続的な編集を許可しつつ、ネットワーク保存はタスクIDで直列化します。Aが処理中でユーザーがBを送信した場合、画面には即座にBが表示され、Bが統合されたキュー内の意図になります。

そのタスクの状態には、確認済みベース、アクティブな操作、および最大1つのキューに入ったタイトルの意図が含まれます。各操作にはクライアントIDと送信するベースバージョンがあります。レンダリングされるタイトルは、キュー内の値、それがなければアクティブな楽観的値、それもなければ確認済みのタイトルです。Aが成功すると、その応答から正規のタスクとバージョンを採用します。Bがまだ保留中であるためAはレンダリングしません。新しいバージョンに対してBを送信します。Aが失敗した場合、Aのみを削除し、失敗が一時的なものであればBの保存を引き続き提供します。バリデーションまたは権限の失敗は、拒否された意図に紐付いたままになります。

別のユーザーがバージョンを進めたとサーバーから報告された場合、自動送信を停止し、現在のタイトルを取得または採用して、解決のためにサーバーの値と提案された値を表示します。古い応答を無視することだけに依存はしません。それではサーバー上で古いリクエストが最後に書き込まれるのを防げないからです。また、中断(abort)をロールバックとして扱うこともしません。

各タスクは、自身の保存中、キュー内、失敗、または競合のステータスを公開します。フォーカスはエディタにとどまり、既存のpoliteなステータス領域が、すべての文字をアナウンスすることなく送信結果をアナウンスします。テストではPromiseの解決とサーバーの処理を独立して制御するため、順序が入れ替わった成功、混在した失敗、古い再フェッチ、競合、失われた応答、リトライ、オフライン復旧、アンマウントに対する最終的なUIとサーバーの値を証明できます。」

よくある間違い

  • ミューテーション前のスナップショットを1つ保存し、エラー時にそれを復元する → 遅れて発生した失敗が新しい楽観的処理を消去してしまう → 失敗した操作を削除し、確認済みベースに残りの意図を加えて再計算する。
  • 最新の応答IDのみを保持する → サーバーが古いリクエストを最後にコミットしている最中にUIが正しく見えてしまう可能性がある → 順序に依存する書き込みを直列化するか、サーバー強制のバージョンまたはシーケンスセマンティクスを要求する。
  • 1つのグローバルなローディングフラグを使用する → 無関係なコントロールがフリーズし、失敗をそのリソースに紐付けることができない → リソースと操作IDごとに保留中およびエラー状態を追跡する。
  • すべてのミューテーションをリプレイ可能として扱う → トグル、差分、削除、不可逆なコマンドには異なる失敗セマンティクスがある → 楽観的な動作を有効にする前に、明示的な意図とマージルールを定義する。
  • キャンセルによってリクエストが取り消されると思い込む → サーバーはキャンセルを認識する前にコミットする可能性がある → 信頼できる状態を調整し、不明な結果に対するリトライを設計する。
  • 任意のフェッチでキャッシュを上書きできるようにする → 古い再フェッチや遅れた応答によって確認済み状態が後退してしまう → リソースバージョンを比較し、最新のベースに対して有効な保留中の意図をリプレイする。
  • UIを即座に感じさせるために保留状態をすべて隠す → ユーザーが後からの修正を理解できず、影響を受けたアクションをリトライできない → 楽観的な値を保持しながら、ローカルの保存中、失敗、競合の状態を表示する。
  • 送信順の成功のみをテストする → 最も困難な競合状態が検証されないままになる → テストマトリクスで応答順序、サーバー順序、失敗、再フェッチ、リトライ、再マウントを制御する。

フォローアップの質問と回答

ユーザーがオフラインで数時間編集できる場合、何が変わりますか?

保留中の操作は永続化され、認証されたアカウントとリソースにスコープされ、クライアントが認可と信頼できるバージョンを更新した後にのみリプレイされる必要があります。キャプチャされたUIスナップショットではなく、意図のセマンティクスと不変の操作IDを保存します。再接続時には、まずベースを取得し、ユーザーが明示的にキャンセルした操作を破棄し、有効なマージルールを持つ操作のみをリプレイします。期限切れの権限、削除されたリソース、スキーマの変更には、目に見えるブロック状態が必要です。長時間のオフラインコラボレーションの場合、単純な楽観的キューでは不十分な場合があります。プロダクトにはドメイン固有のマージ操作や共同編集プロトコルが必要になる場合があります。

サーバーがバージョンを返す場合、すべてのミューテーションを並列実行できますか?

いいえ。バージョンは応答がどの状態を表しているかをクライアントに伝えますが、2つの書き込みをどのように順序付けまたはマージすべきかを自動的に定義するわけではありません。サーバーが送信されたベースバージョンをアトミックにチェックするか、シーケンスを強制するか、あるいは独立した操作や可換な操作を公開している場合にのみ並列処理は安全です。そうでなければ、2つの絶対的な書き込みが間違った順序で到着してコミットされる可能性があります。順序に依存する1つのリソースに対しては、多くの場合、直列化の方が明確な契約となります。

サーバーが実際のIDを割り当てる場合、楽観的な作成をどのように処理しますか?

レンダリング前に不変のクライアントIDを作成し、それを操作識別子および一時的なリストキーとして使用します。成功時にはサーバーIDへのマッピングを記録し、無関係な行を再マウントすることなく確認済みのエンティティを置き換えます。サーバーがサポートしている場合は、操作識別子によってリトライを重複排除します。作成が失敗した場合は、その楽観的エンティティのみを削除またはマークします。一時エンティティを対象とする後続の操作は、マッピングを待つか、確認後にターゲットを書き換えることができるキューで表現する必要があります。

あえて悲観的UI(Pessimistic UI)を選択するのはどのような場合ですか?

成功を表示することがユーザーに重大な誤解を与える場合、リカバリが局所的でない場合、競合が頻繁に発生する場合、またはアクションが不可逆であるか高リスクである場合に選択します。決済、権限の付与、法的な送信、破壊的な一括操作では、実際の保留状態を表示しながらクリックを即座に受け付け、信頼できる確認が得られた後にのみ成功を表示します。まだ発生していない結果を主張することなく、インターフェースの応答性を保つことができます。

公開情報ソース

関連する質問