TT Lab
はじめる
学ぶ 学習パス コース

状態管理 — ライブラリを自分で作ってみる

先に送ったリクエストが後から届く

TT Labで続きを見る

一言でいうと

非同期の更新では、あとから送ったリクエストがあとに届くという保証はありません。 だから 状態を書き換える場所には、「このレスポンスはまだ有効か」を問う仕組みが必ず必要です。

なぜ必要なのか

検索ボックスに서、서울、서울역と順に入力します(韓国語の「ソウル駅」を1文字ずつ入力していく例です)。リクエストは3回送られ、画面には 最後の結果が残るはずです。ところが、ある日は서の結果が画面に残ります。

理由は単純です。最初のリクエストはキャッシュに当たらず800msかかり、3つ目のリクエストは 40msで返ってきました。3つ目の結果が先に画面に入り、800ms後に最初の レスポンスが届いてその上に上書きしました。 コードには誤りがないように見えます。

const res = await fetch("/search?q=" + q)
setResults(await res.json())    // 누가 언제 도착했는지 아무도 묻지 않는다

このバグの厄介な点は、開発者のマシンで再現しないことです。ローカルでは レスポンスが5msで返るので、順序が入れ替わる隙がありません。地下鉄で使っているユーザーにだけ見えます。

同じ種類の事故がほかに3つあります。同じデータを画面の4か所がそれぞれ要求して同じ リクエストが4回送られること、ロールバックの作りが悪くて他人の更新まで巻き戻してしまうこと、 キャンセルしなかったリクエストが、消えた画面の状態を触り続けることです。

どう動くのか

最新優先: 連番を付ける

いちばん安くて確実な方法は、リクエストごとに番号を振り、レスポンスを反映する前にその番号が まだ最新かどうかを確認することです。

let seq = 0
async function run(task) {
  const my = ++seq
  const value = await task()
  if (my !== seq) return { applied: false }   // 나는 이미 낡았다
  return { applied: true, value }
}

大事なのは、捨てるほうがデフォルトだという点です。「遅れて届いたものを無視する」ではなく、 「自分が最新のときだけ反映する」と書くことで、リクエストが3つでも10個でも同じ ルールが成り立ちます。

キャンセル: 結果を捨てることと処理を止めることは別です

番号で弾けば画面は正しくなりますが、ネットワークとサーバーは働き続けます。ブラウザの標準は そのためにAbortControllerを用意しています。MDNは、abort()が 「fetchリクエスト、レスポンス本文の消費、ストリーム」を中断すると書いています (AbortController)。 新しいリクエストを始めるときに前のリクエストのsignalを切っておけば、2つが同時に解決します。 古いレスポンスが届かなくなり、サーバー側の枠も返せます。

重複排除: 同じキーは1回だけ

画面の4か所が同じuser/42を必要とすると、リクエストも4回送られます。進行中の リクエストをキーで覚えておき、同じ約束(Promise)を分け合うようにすれば1回に 減ります。TanStack Queryが標準で行っているのがこれで、同じドキュメントは、失敗したクエリを 指数バックオフで3回リトライすると書いています (Important Defaults)。

楽観的更新: ロールバックは引き算ではない

「いいね」ボタンを押すと、サーバーのレスポンスを待たずに先に画面を変えます。失敗したら 元に戻します。ここで、ほとんど誰もが同じ間違いをします。ロールバックを逆演算で組んでしまうのです。

like()            // count + 1
unlike()          // 실패하면 count - 1

その間に別の人が「いいね」を押してサーバーの値が変わっていたら、引き算はおかしな値を 作ります。順序のある更新なら、もっと悪くなります。ラベルをAに変える更新とBに 変える更新が両方とも待機中のとき、前のほうをロールバックするとラベルはBになるべきなのに、 逆演算はAより前に戻してしまいます。

正しい方法は、確定したベース値(base)と待機中の更新リストを別々に持ち、画面は 常にベース値の上に待機リストを順番に重ね直して計算することです。ロールバックは リストからその項目を取り除くことで、サーバーのレスポンスが来たらベース値を差し替えてから、残りの 待機リストを重ね直します。ReactのuseOptimisticも同じ形です。確定値と リデューサーを渡すと、待機中の更新をその上に重ねて見せてくれます (useOptimistic)。

現場での姿

タブの切り替えが速いダッシュボードで、「たまに古いタブのデータが見える」という報告が入りました。 タブごとにリクエストを送り、レスポンスをそのまま状態に入れていたのですが、2つのタブを素早く行き来すると 最初のタブのレスポンスがあとから届いていました。再現できないまま1か月を超え、最終的に ネットワークを遅く設定したブラウザで一発で再現できました。直したのは、連番の比較3行でした。

別のチームは、「いいね」のロールバックを引き算で組んでいたため、サーバーが500を返した数秒間に 他のユーザーの「いいね」まで減らしてしまい、カウントがマイナスになりました。ロールバックが「引き算」ではなく 「待機リストから取り除いて再計算」だったなら、起きなかったことです。

2つの事故とも、画面のコードではなく、状態を書き換える場所に順序の概念がなかったために 起きました。ライブラリを変えても、その場所に順序がなければそのままついてきます。

次のラボですること

lane.mjs1つに、4つのものを自分で作ります。最新優先のレーン、キャンセルシグナル、キーごとの 重複排除、ベース値と待機リストで計算する楽観的更新です。そして最後に、 同じシナリオを自分で実行して数値を書き、採点ツールがその場で測り直して照合します。 でっち上げた数値では通りません。