速いUIと正しいUIのあいだで、どのズレを許容するか
はじめに
現代のフロントエンドでは、「速いUI」を作るための技術がたくさん出てくる。
- optimistic update
- Suspense
- transition
- stale-while-revalidate
- debounce
- background sync
どれも実務で見かける技術だが、個別に覚えているだけだと、少し整理しづらい。
optimistic update はサーバーの応答を待たずに画面を更新する。Suspense はまだ準備できていないUIの代わりに fallback を出す。transition は緊急ではない更新を後回しにする。stale-while-revalidate は古いデータを見せながら裏で更新する。
これらは一見、別々のテクニックに見える。しかし根本では、同じ問題を扱っている。
それは、ユーザーが操作した時間と、システムが正しい状態に到達する時間が一致しない、という問題だ。
UIは、ユーザーの操作にすぐ反応してほしい。一方で、正しい状態はサーバー、DB、キャッシュ、ネットワーク、他ユーザーの操作、バックグラウンド処理などを経由して、少し遅れて確定する。
つまり、即時反応と正確性はしばしば衝突する。
この記事では、React / Next.js / TanStack Query を前提に、現代フロントエンドがなぜ「整合性そのもの」ではなく、「整合性のズレが体験としてどう知覚されるか」を設計するようになったのかを整理する。
速いUIとは、処理が速いUIとは限らない
まず分けて考えたいのは、次の2つだ。
- 技術的に処理が完了している
- ユーザーが反応したと感じている
この2つは同じではない。
たとえば、ユーザーが「いいね」ボタンを押したとする。サーバーへの mutation が 500ms で完了するなら、技術的には十分速いように見えるかもしれない。
しかし、ボタンが 500ms 無反応だったら、ユーザーは「押せていないのかもしれない」と感じる。逆に、サーバー処理に 1秒かかっても、押した瞬間にボタンの見た目が変わり、裏で保存中であることが分かれば、体験としては速く感じる。
ここで重要なのは、ユーザーが見ているのは通信完了時刻ではなく、自分の操作がシステムに届いたという感覚だということだ。
この考え方は、HCIでは昔から研究されている。Nielsen Norman Group の Jakob Nielsen は、UIの応答時間について、0.1秒、1秒、10秒という代表的な境界を示している。
特に 0.1秒程度の反応は、ユーザーに「自分が直接操作している」という感覚を与える。これはフロントエンドの文脈で言えば、サーバー処理が終わったかどうかとは別に、まず操作に対する局所的な反応を返すべきだ、という話でもある。
つまり、フロントエンドが設計しているのは実時間だけではない。ユーザーがどう待ち、どう理解し、どう安心するかという知覚時間でもある。
なぜ整合性は遅れてやってくるのか
昔のUIでは、状態は比較的単純だった。画面の値はローカルにあり、ユーザーが入力すればそのまま更新される。もちろん実際にはサーバー通信もあったが、多くの画面では「送信するまではローカル」「送信後に完了画面」という区切りがはっきりしていた。
現代のWebアプリケーションでは、この前提が崩れている。
- 複数の画面やコンポーネントが同じ server state を読む
- 複数ユーザーが同じデータを更新する
- キャッシュがある
- SSR、Streaming、Hydration がある
- オフラインや再接続がある
- バックグラウンドで再取得が走る
- サーバー側も非同期処理や分散システムになっている
この状況では、「今画面に出ている値」が絶対に最新であるとは言いづらい。
TanStack Query の公式ドキュメントでは、デフォルトで query のキャッシュデータは stale と見なされ、条件に応じてバックグラウンドで再取得されることが説明されている。
これは単なるライブラリ都合ではない。server state は本質的に、時間付きの値だからだ。
user.name = "Alice" という値があるとして、それは単に "Alice" なのではない。より正確には、「ある時刻に観測した限りでは Alice だった」という値である。
クライアントがその値を取得した直後に、別のユーザーが名前を変えるかもしれない。バックグラウンドジョブがステータスを更新するかもしれない。自分の mutation が成功した直後でも、次に読む replica や cache によっては古い値が返るかもしれない。
Martin Fowler は microservices の trade-off を説明する記事の中で、eventual consistency によって「更新したはずなのに、直後の画面ではまだ反映されていない」ように見える問題を、ユーザー体験上かなり厄介なものとして扱っている。
これはバックエンドだけの話ではない。フロントエンドは、その不確実な途中状態をユーザーに見せる最後の層にいる。
整合性には種類がある
「整合性」と言うと、ついDBや分散システムの整合性を想像しがちだ。しかしUI設計では、もう少し分けて考えたほうが実務的だ。
ここでは次の3つに分ける。
| 種類 | 問い | 例 |
|---|---|---|
| 表示整合性 | 画面に出ている情報は、ユーザーに誤解を与えないか | 一覧の件数と表示アイテムが合っているか |
| 操作整合性 | ユーザーの操作結果が、操作モデルとして破綻していないか | ボタンを押したのに何も起きないように見えないか |
| 業務整合性 | ビジネスルールとして許されない状態を作っていないか | 在庫超過注文、二重決済、権限違反 |
この3つは、同じ強さで守る必要はない。
たとえば、SNSの「いいね」数が数秒だけ古いのは、多くの場合、表示整合性の軽いズレで済む。一方、決済完了前に「購入済み」として扱うのは、業務整合性を壊す可能性がある。
検索入力で debounce をかけると、入力欄の値と検索結果の query が一時的にズレる。これは表示整合性のズレだが、入力操作そのものは同期的に反応しているので、操作整合性は守られている。
このように、現代UIの設計では「整合性を守るか壊すか」ではなく、どの整合性を、どの時間幅で、どの程度まで許容するかを考える必要がある。
loading / success / error だけでは足りない
従来の非同期UIは、よく次の3状態で表現されてきた。
- loading
- success
- error
もちろん、この分類は今でも必要だ。しかし現代のUIを表すには粗すぎる。
実際の画面には、もっと多くの途中状態がある。
- stale: 表示中のデータはあるが、最新とは限らない
- refreshing: 既存データを見せながら再取得している
- optimistic pending: 成功した前提で見せているが、まだ確定していない
- streaming: UIの一部だけが先に届いている
- partially hydrated: HTMLは見えているが、まだ完全には操作できない
- offline queued: 操作は受け付けたが、送信は再接続後になる
- retrying: 失敗したが、自動または手動で再試行できる
これらは実装上の細かい状態に見えるかもしれない。しかしUX上はかなり重要だ。
なぜなら、ユーザーにとって危険なのは「壊れていること」そのものよりも、何が起きているかわからないことだからだ。
画面が失敗していても、「保存できませんでした。再試行できます」と分かれば、ユーザーは次の行動を選べる。一方、画面が何も変わらず、保存されたのか、失敗したのか、まだ処理中なのか分からない状態は危険だ。
skeleton、pending UI、retry UI は、単に見た目をよくするためにあるのではない。これらは、システムの途中状態を説明可能にするためのUIである。
各技術は、どの不整合を許容しているのか
ここからは、よく使われる技術を「何を便利にするか」ではなく、「どういうズレを許容する技術なのか」という観点で見る。
optimistic update: 確定前の未来を一時的に見せる
optimistic update は、mutation が成功する前にUIを更新する手法だ。
TanStack Query の公式ドキュメントでも、mutation 完了前にUIを楽観的に更新する方法が説明されている。
これは単に「速く見せる」技術ではない。より正確には、サーバーで確定していない状態を、成功する見込みの高い未来として一時的に表示する技術である。
許容しているズレは、次のようなものだ。
| 観点 | 内容 |
|---|---|
| 許容するズレ | UI上の状態が、サーバー確定前に未来の状態へ進む |
| 守りたいもの | 操作した瞬間に反応した感覚 |
| 失敗時に必要なもの | rollback、retry、エラー表示、pending 表現 |
| 向いている操作 | 成功率が高く、失敗しても回復できる操作 |
| 向かない操作 | 決済、在庫確保、権限変更など業務整合性が強い操作 |
典型例は「いいね」「お気に入り」「並び替え」「軽いメモ保存」などだ。これらは、ユーザーの意図が明確で、失敗時にも取り消しや再試行が比較的しやすい。
一方で、optimistic update を使うべきではない場面もある。
たとえば、決済処理で「購入完了」と楽観的に表示するのは危険だ。決済は失敗することがあるし、業務上の確定点が重要だからだ。この場合は、即時に見せるべきなのは「購入完了」ではなく「処理を受け付けました」「決済中です」という状態である。
つまり optimistic update の設計で重要なのは、どこまで未来を見せてよいかを決めることだ。
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.map((todo) =>
todo.id === input.id ? { ...todo, title: input.title } : todo,
),
)
return { previousTodos }
},
onError: (_error, _input, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
このコードでやっていることは、「キャッシュを更新する」だけではない。ユーザーに一時的な未来を見せ、失敗したら過去に戻し、最後にサーバーの正しい状態と照合している。
debounce: 操作と問い合わせを意図的にズラす
debounce は、入力のたびに処理を走らせず、一定時間入力が止まってから処理を実行する手法だ。
検索UIでよく使われる。
const [inputValue, setInputValue] = useState('')
const debouncedKeyword = useDebouncedValue(inputValue, 300)
const searchQuery = useQuery({
queryKey: ['search', debouncedKeyword],
queryFn: () => searchProducts(debouncedKeyword),
enabled: debouncedKeyword.length > 0,
})
debounce が許容しているズレは、入力欄の値と検索に使われる値が一時的に一致しないことだ。
ユーザーが react q と打った瞬間、入力欄はすぐ react q になる。しかし検索結果はまだ react の結果かもしれない。300ms 後に react q の検索が走る。
このズレは、操作整合性を守るために表示整合性を少し遅らせている、と見ることができる。
入力そのものを遅らせてはいけない。ユーザーのキーボード操作は即時に反応すべきだからだ。一方、検索結果は少し遅れてもよい。むしろ毎キーごとに結果が激しく変わるほうが、知覚上は不安定になることもある。
debounce は「速くする」技術というより、ユーザーの意図が固まるまで問い合わせを待つ技術である。
startTransition: 緊急な更新と遅れてよい更新を分ける
React の startTransition は、ある状態更新を transition としてマークする。
React公式ドキュメントでは、transition は non-blocking な更新として扱われ、不要な loading indicator を避ける用途も説明されている。
ここで重要なのは、Reactがすべての更新を同じ優先度で扱わないことだ。
たとえば、タブ切り替えや検索条件の変更で重いリストを再描画する場合、入力欄やボタンの反応まで巻き込んで遅くなると、ユーザーはUI全体が固まったように感じる。
startTransition は、次のようなズレを許容する。
| 観点 | 内容 |
|---|---|
| 許容するズレ | 入力や選択の即時状態と、重い派生UIの反映タイミングがズレる |
| 守りたいもの | 入力、クリック、ナビゲーションなどの操作感 |
| 表現すべき状態 |
isPending による「切り替え中」 |
const [isPending, startTransition] = useTransition()
const [tab, setTab] = useState('overview')
function selectTab(nextTab: string) {
startTransition(() => {
setTab(nextTab)
})
}
これは「Reactを速くする魔法」ではない。重い更新を緊急ではないものとして扱い、UIの反応順序を設計するための仕組みだ。
Reactチームの思想として重要なのは、すべてを同期的に完了させるのではなく、ユーザーにとって重要な反応を先に守ることだと言える。
Suspense: まだ準備できていない部分を境界で説明する
Suspense は、子コンポーネントがまだレンダーできないときに fallback を表示する仕組みだ。
React公式ドキュメントでは、<Suspense> は children が読み込みを完了するまで fallback を表示するものとして説明されている。
Suspense が許容しているズレは、画面全体が同時に準備できるとは限らないというズレだ。
Next.js App Router の streaming では、この考え方がさらに前面に出ている。Next.js公式ドキュメントでは、Streaming によってページを小さなチャンクに分け、準備できた部分から段階的に送れることが説明されている。
これは、「すべてのデータが揃うまで真っ白な画面で待つ」設計から、「重要な枠組みや先に出せる部分を見せながら、遅い部分を説明する」設計への変化だ。
ただし、Suspense の fallback は万能ではない。
画面の主役がまだないなら skeleton は自然だ。一方で、すでに表示されているコンテンツを毎回 fallback に戻すと、ユーザーは画面が巻き戻ったように感じる。React の Suspense ドキュメントでも、すでに表示されたコンテンツを隠さないために transition や deferred value と組み合わせる考え方が示されている。
Suspense boundary は、技術的な分割単位ではなく、ユーザーにとって「ここはまだ準備中」と理解できる単位で置くべきだ。
この「どこまでを画面成立に必要なデータとみなすか」という考え方は、以前私が書いた「メインデータ」と「補助データ」を分けて考えるデータ取得設計の記事でも整理している。
Suspense やローディング設計を、UX とデータ依存の観点から掘り下げている。
stale-while-revalidate: 古いが使える値を見せ続ける
stale-while-revalidate は、古いデータを表示しながら、裏で新しいデータを取りに行く考え方だ。
TanStack Query の staleTime や background refetch は、この考え方と相性がよい。重要なのは、stale が「壊れている」ではなく、「古い可能性がある」という状態であることだ。
たとえば、ダッシュボードの集計値が30秒古いとしても、多くの業務では許容できる。むしろ毎回空の loading に戻すほうが、操作の流れを壊す。
この技術が許容しているズレは、次のようなものだ。
| 観点 | 内容 |
|---|---|
| 許容するズレ | 表示中のデータが最新ではない可能性 |
| 守りたいもの | 画面の安定性、再訪時の速さ、操作継続性 |
| 表現すべき状態 | stale、refreshing、last updated |
ここで大事なのは、「古い値を見せるなら、古い可能性があることをUI上どう扱うか」だ。
すべての stale データにラベルを出す必要はない。だが、意思決定に影響するデータなら、最終更新時刻や更新中表示を出したほうがよい。
たとえば、売上ダッシュボードの数値なら「最終更新: 10:32」「更新中...」があるだけで、ユーザーはその値の扱い方を判断しやすくなる。
eventual consistency: いつか揃う前提をUIに持ち込む
eventual consistency は、分散システムの文脈では、更新後すぐに全ての読み取りが同じ値を返すとは限らないが、更新が止まれば最終的には収束する、という考え方だ。
AWS DynamoDB では、デフォルトの read consistency は eventual consistency である。
つまり、書き込み直後の読み取りが常に最新値を返すとは限らない。
フロントエンドでは、これが次のような体験として現れる。
- 保存した直後に一覧へ戻ると、まだ古い値が出る
- 通知バッジの件数と一覧の件数が一時的に合わない
- 別タブで更新した内容が、今のタブに少し遅れて反映される
- オフライン中に作ったデータが、再接続後にまとめて送信される
eventual consistency を前提にするなら、UIは「必ず即座に揃う」と見せてはいけない。
必要なのは、ズレをゼロにすることではなく、ズレが起きたときにユーザーが理解できる状態にすることだ。
- 保存直後は、詳細画面の値を mutation response で更新する
- 一覧は invalidate して background refetch する
- 反映に時間がかかる処理は「処理中」「反映待ち」として表示する
- 他ユーザーの変更は「新しい更新があります」として明示する
- オフライン操作は「送信待ち」としてキューに見せる
Figma の multiplayer に関する記事でも、同時編集、オフライン、再接続、状態の収束といった問題が扱われている。Figma は完全なCRDTそのものではないと説明しつつも、CRDTから影響を受け、最終的に参加者の状態が発散し続けないことを重要な制約としている。
リアルタイム共同編集のような極端な例を見ると、「正しいUI」とは単に常に最新値を表示することではなく、遅延や競合がある中で、ユーザーが納得できる形で状態を収束させることだと分かる。
skeleton / pending / retry は説明可能性のUIである
途中状態を設計するとき、よく使われる表現がある。
- skeleton
- spinner
- pending label
- disabled button
- retry button
- toast
- inline error
- last updated
- sync status
これらは装飾ではない。説明可能性のUIである。
ユーザーは、内部の query cache や mutation queue や hydration 状態を知らない。だからフロントエンドは、それをユーザーの言葉に翻訳する必要がある。
たとえば、同じ「保存中」でも、実際にはいくつかの意味がある。
| 内部状態 | ユーザーに伝えるべきこと |
|---|---|
| mutation pending | 保存処理を受け付けた |
| optimistic pending | 画面には反映したが、まだ確定していない |
| retrying | 一度失敗したが、再試行している |
| offline queued | 今は送れないが、後で送る |
| server processing | サーバー側で処理が続いている |
これらを全部「loading」と呼ぶと、設計が粗くなる。
「保存中」と出すのか、「送信待ち」と出すのか、「反映に時間がかかります」と出すのかで、ユーザーの理解は変わる。
壊れていることより、何が起きているかわからないことのほうが危険だ。なぜなら、ユーザーが次の行動を選べなくなるからだ。
実務ではどう判断するか
実務で重要なのは、技術を先に選ばないことだ。
「この画面に optimistic update を入れるか」ではなく、まず次のように考える。
1. どの整合性を守る必要があるか
まず、表示整合性、操作整合性、業務整合性を分ける。
- 表示が少し古いだけなら許容できるか
- 操作した結果が即時に見えないと不安になるか
- 業務上、確定前に見せてはいけない状態か
業務整合性が強いなら、optimistic update ではなく pending UI を選ぶことが多い。表示整合性だけの問題なら、stale-while-revalidate や background refetch で十分なことが多い。
2. ユーザーは何を確信できればよいか
ユーザーが必ずしも知りたいのは、内部処理の完了ではない。
多くの場合、知りたいのは次のどれかだ。
- 操作は受け付けられたか
- 今も処理中なのか
- 失敗したのか
- 自分が次に何をすればよいのか
- 表示中の値を信じてよいのか
これが分かるなら、処理完了まで待たせなくてもよい場面は多い。
逆に、ここが分からないなら、どれだけ内部処理が速くても不安なUIになる。
3. ズレたときの戻し方を決める
ズレを許容するなら、必ず戻し方が必要になる。
- optimistic update に失敗したら rollback するのか
- retry するのか
- ユーザーに競合解決を求めるのか
- サーバー値を優先するのか
- ローカル変更を保持するのか
ここを決めずに「速く見せる」だけを入れると、失敗時に体験が破綻する。
optimistic update は成功時の体験だけでなく、失敗時の体験まで含めて設計する必要がある。
4. boundary をユーザーの理解単位に合わせる
Suspense boundary、ErrorBoundary、Query の分割、retry UI の単位は、実装都合だけで決めないほうがよい。
ユーザーにとって「この部分はまだ準備中」と理解できる単位で分ける。
たとえば、商品詳細ページでは、商品名や価格が主役ならページ全体の skeleton が自然かもしれない。一方、レビューや関連商品は遅れてもよい補助情報なので、セクション単位の skeleton や fallback のほうが自然だ。
これは Next.js の streaming とも相性がよい。ページ全体を一枚岩で待つのではなく、重要な shell を先に見せ、遅い部分を境界で説明する。
5. 「速く見える」ことと「嘘をつく」ことを分ける
速いUIは、ユーザーに嘘をつくUIではない。
確定していないものを確定したように見せるなら危険だ。一方で、確定前でも「受け付けた」「反映中」「送信待ち」と表現するなら、それは嘘ではない。
重要なのは、UIの言葉を正確にすることだ。
たとえば、決済ボタンを押した直後に表示するなら、次の2つは意味が違う。
- 購入が完了しました
- 決済を処理しています
前者は業務上の確定を意味する。後者は操作を受け付けたことを意味する。
この差を曖昧にすると、速いUIではなく、危ないUIになる。
設計のための簡易マトリクス
実務では、次のように整理すると判断しやすい。
| 操作・状態 | 許容できるズレ | 向いている技術 | 注意点 |
|---|---|---|---|
| いいね | 確定前に反映される | optimistic update | 失敗時の rollback / retry |
| 検索入力 | 入力と検索結果が少しズレる | debounce / deferred value | 入力欄は同期的に更新する |
| タブ切り替え | 重い表示が遅れて反映される | startTransition | pending を見せる |
| ダッシュボード | 数値が少し古い | stale-while-revalidate | 最終更新時刻が必要な場合がある |
| 商品レビュー | セクションだけ遅れる | Suspense / streaming | boundary を局所化する |
| 決済 | 確定前に完了扱いしない | pending UI | optimistic に完了表示しない |
| オフライン投稿 | 送信が後回しになる | background sync / mutation queue | 送信待ちを明示する |
| 複数人編集 | 他者変更が遅れて届く | realtime sync / conflict handling | 競合と収束のルールが必要 |
この表で重要なのは、「技術」と「許容するズレ」を一緒に見ることだ。
技術選定は、許容できる不整合の設計から逆算する。
まとめ
速いUIとは、単にAPIレスポンスが速いUIではない。
ユーザーの操作に対して、適切な時間幅で反応し、途中状態を説明し、必要な整合性を壊さず、許容できるズレを体験として処理するUIである。
optimistic update は、確定前の未来を一時的に見せる。debounce は、操作と問い合わせを意図的にズラす。startTransition は、緊急な更新と遅れてよい更新を分ける。stale-while-revalidate は、古いが使える値を見せ続ける。eventual consistency は、すぐには揃わない世界を前提にする。
これらは単なる高速化テクニックではない。
どの不整合を許容し、どの不整合は許容しないかを決めるための設計手段である。
現代フロントエンドは、整合性を消すのではなく、どのズレなら体験として許容されるかを設計する世界になっている。
Discussion