問題と該当するコンテキスト
リアルタイムの注文を購読するReact 19.2のダッシュボードを考えます。
function LiveOrders({ tenantId }: { tenantId: string }) {
const [filter, setFilter] = useState("all")
const [count, setCount] = useState(0)
useEffect(() => {
const connection = connect(tenantId)
connection.on("order", (order) => {
if (matches(order, filter)) {
setCount(count + 1)
}
})
return () => connection.close()
}, [])
// Rendering and controls omitted.
}ユーザーがフィルターを変更した後も、コールバックは依然として"all"を適用します。また、注文が連続してバースト発生すると、カウントが1のままになることがあります。コンポーネントが別のtenantIdを受け取った場合でも、最初のテナントに接続されたままになります。参照されているすべての値を依存配列に追加すると値の最新性は修正されるように見えますが、今度はカウントやフィルターが変更されるたびに接続が切断・再作成されてしまいます。
これらの不具合が発生する理由、どの値が古くなっているかを証明する方法、そしてコールバックが最新のコミットされた値を参照しつつ購読の意図したライフタイムを維持する方法を説明してください。
これは実践的なフロントエンド面接の質問です。英語と中国語の双方における公開React面接ガイドでも、古いクロージャ(stale closure)、Hookのルール、useEffectの依存関係が明示的にテストされているためです。より本質的なスキルは、単一の回避策を暗記することではありません。ある値がEffectを再起動すべきか、状態遷移に関与すべきか、あるいはEffectが保持するコールバックによって非リアクティブに読み取られるべきかを適切に判断することです。
面接官が評価しているポイント
第1に、候補者がそのメカニズムを正確に説明できるかです。各レンダーはpropsとstateのスナップショットを受け取ります。そのレンダー中に作成された関数は、そのスナップショットをキャプチャ(クロージャ)します。Reactは、その後のレンダー後にローカルのfilter、count、tenantId変数を変更(ミューテート)することはありません。外部システムが最初のコールバックを保持し続けている場合、そのコールバックは最初のレンダー時の値を読み取り続けます。クロージャ自体は正常に動作しており、Effectで宣言された同期の契約が不完全なのです。
第2に、候補者が4つの異なる要件を切り分けられるかです。
| 要件 | 適切なツール | 何を変更するか |
|---|---|---|
| 外部リソースがリアクティブな値に追従する必要がある | Effectの依存配列 | Effectのクリーンアップと再同期を行う |
| 次の状態が前の状態から導出される | 関数型アップデータ | Reactのキューにある現在の状態から計算する |
| Effectが保持するコールバックが再購読なしで最新のコミットされた値を必要とする | React 19.2のuseEffectEvent | 現在のpropsやstateを非リアクティブに読み取る |
| レンダーされない命令型の可変データに安定した同一性(identity)が必要である | Ref | 手動で同期される可変値を保持する |
第3に、候補者が誤った解決策に惑わされないかです。useCallback(fn, [])は関数の同一性を維持しますが、初回のレンダー時のクロージャも維持してしまいます。exhaustive-depsを抑制することは、セマンティクスを変更するのではなく証拠を隠すだけです。カウンタのインクリメントごとにWebSocketを再作成すれば値は最新になりますが、リソースのライフタイムとしては不適切です。
最後に、候補者がクリーンアップと非同期の順序制御を網羅できるかです。新しいクロージャを使っても、古いリクエストのキャンセル、順不同なレスポンスの防止、リークしたイベントリスナーの削除は行われません。これらは独立した並行性とライフサイクルの責務です。
回答前の確認質問
- どの値が購読の同一性を定義しているか?
tenantIdがサーバー側のストリームを選択している場合、それを変更したら再接続する必要があります。表示用フィルターは通常、再接続すべきではありません。 - フィルター変更時に過去のイベントを再評価すべきか? これにより、フィルターがコールバック内にのみ属するのか、それとも新しいクエリや購読をトリガーすべきかが決まります。
- コールバックは前の状態から計算を行っているか? カウントのインクリメントは、コールバックが他の最新値を読み取る場合であっても、関数型アップデータを使用すべきです。
- どのReactバージョンがデプロイされているか?
useEffectEventはReact 19.2で利用可能です。古いバージョンでは、注意深く同期されたrefが互換性のためのフォールバックとなります。 - 外部APIは再接続なしでリスナーを差し替えることができるか? ライブラリによっては
subscribeとupdateHandlerの操作が分かれており、異なる最小ライフタイムをサポートできる場合があります。 - コールバックから非同期リクエストが開始されるか? その場合、キャプチャされた値の解決に加えて、キャンセル処理やリクエスト世代(generation)のチェックを定義する必要があります。
- 開発環境でStrict Modeが有効になっているか? その追加のセットアップとクリーンアップのサイクルによりクリーンアップ漏れを検出できますが、古いクロージャ自体を作り出すわけではありません。
30秒の回答フレームワーク
「Reactのコールバックは、それを作成したレンダーのpropsとstateのスナップショットを読み取ります。ここではEffectが一度しか実行されないため、接続は最初のtenant、filter、countを保持し続けます。私なら、接続先リソースを変更する値であるtenantIdを購読の依存配列に指定します。countは前の状態から導出されるため、setCount(current => current + 1)で更新します。注文コールバックが再接続なしで最新のfilterを読み取れるようにするため、React 19.2ではその非リアクティブなロジックをuseEffectEventに配置し、Effectが保持するリスナーからそれを呼び出します。依存関係のlinterを抑制したり、最新性の解決策としてuseCallback([])を使用したりはしません。Refはレンダーされない可変データに対する手動の互換性オプションであり、非同期の結果には依然としてキャンセル処理やバージョニングが必要です。」
ステップごとの詳細解説
ステップ1:すべてのレンダーを不変のスナップショットとしてモデル化する。
初回のレンダー時、これらの値が以下であると仮定します。
tenantId = "tenant-a"
filter = "all"
count = 0Effectは接続を作成し、まさにそれらのローカル変数を参照するコールバックを作成します。その後、setFilter("paid")は新しいfilter変数を持つ別のレンダーを生成します。元のコールバックがキャプチャした変数が書き換えられるわけではありません。空の依存配列はEffectを二度と同期し直す必要がないことを示しているため、外部接続は元のコールバックを保持し続けます。
これにより、推測に頼ることなく3つのバグすべてを予測できます。
matches(order, filter)は"all"を使い続けます。- 一致する各イベントは
setCount(0 + 1)を呼び出すため、イベントが連続するとインクリメントが合成されず、状態が1で上書きされます。 - propが変更された後も、接続は
"tenant-a"に関連付けられたままになります。
有用なデバッグログには、レンダーのシーケンス番号、レンダー中に確認された値、保持されたコールバック内で確認された値が含まれます。接続IDも個別に記録してください。これにより、コールバックが古いのか、購読が再起動しなかったのか、あるいはサーバーが予期しないデータを送信したのかを識別できます。
ステップ2:依存配列を編集する前に、セマンティクスごとに各読み取りを分類する。
Effectが各値を読み取る理由を問い直します。
tenantIdは、同期する外部ストリームを決定します。これはリアクティブであり、依存配列に含めるべきです。countは次のカウントを計算するためにのみ必要です。読み取りを関数型アップデータに置き換えることで、キャプチャする必要がなくなります。filterは今後のイベントの処理方法に影響しますが、これを変更してもテナント接続を破棄すべきではありません。リスナーは、購読をフィルターに対してリアクティブにすることなく、最新の値を読み取る必要があります。
この分類により、依存配列を空にする極端な手法と、レンダー関連の変更のたびに高コストなリソースを再起動してしまう依存配列という、2つの極端な状態を回避できます。
ステップ3:関数型アップデータで状態遷移を修正する。
以下を:
setCount(count + 1)このように変更します:
setCount((current) => current + 1)Reactはアップデータをキューに入れ、次のレンダーが計算されるときに現在の保留中(pending)の状態を渡します。したがって、コールバックが以前に登録されていた場合やReactが更新をバッチ処理する場合でも、10回のイベントによって10回のインクリメントが正しく合成されます。これはcountの依存関係を修正するだけです。filterやtenantIdを最新にするわけではありません。
ステップ4:リソースを真に再同期させる依存関係を宣言する。
接続の同一性にはtenantIdが含まれるため、Effectはこれに依存する必要があります。テナントが変更されると、Reactはまず以前のクリーンアップを実行し、その後に新しい接続をセットアップします。クリーンアップでは、以前のテナントからのイベントが現在の画面を更新しないように、リスナーを削除するか古い接続を切断する必要があります。
filterとcountを追加すれば、技術的には新しいコールバックごとに最新の値が渡されますが、接続のライフタイムも再定義されてしまいます。カウンタの更新によって切断と再接続が発生することになります。これにより、イベントの欠落、クリーンアップ重複時のイベント二重処理、サーバー側カーソル状態のリセット、不要な負荷が発生する可能性があります。依存配列は動作の仕様であり、linterを黙らせるために編集するパフォーマンスのヒントではありません。
ステップ5:React 19.2で最新の非リアクティブな読み取りを行うためにEffect Eventを使用する。
修正されたコンポーネントでは、接続のライフタイムとコールバックの最新性を分離して維持できます。
function LiveOrders({ tenantId }: { tenantId: string }) {
const [filter, setFilter] = useState("all")
const [count, setCount] = useState(0)
const onOrder = useEffectEvent((order: Order) => {
if (matches(order, filter)) {
setCount((current) => current + 1)
}
})
useEffect(() => {
const connection = connect(tenantId)
connection.on("order", onOrder)
return () => connection.close()
}, [tenantId])
// Rendering and controls omitted.
}onOrderは常にコミットされた最新のfilterを参照しますが、EffectはtenantIdが変更されたときにのみ再同期されます。Effect Eventは、Effect内またはそれが登録するリスナーなど、そのEffectに接続されたコードから呼び出されます。これは一般的なイベントハンドラーの代替ではなく、コンポーネントツリー全体に不用意に渡すべきではありません。また、本来同期を再起動すべき値を隠蔽するために使用してはなりません。
Effect Eventを依存配列に追加しないでください。現在のReactのlintルールは、その非リアクティブな役割を理解しています。linterが別の値を報告した場合は、その警告を設計へのフィードバックとして受け止め、ルールを無効化するのではなくコード構造を変更してください。
ステップ6:refが適切な場面とそのコストを理解する。
React 19.2より前では、最新の値をrefに保存するのが一般的な互換性パターンでした。
const filterRef = useRef(filter)
useEffect(() => {
filterRef.current = filter
}, [filter])
useEffect(() => {
const connection = connect(tenantId)
connection.on("order", (order) => {
if (matches(order, filterRef.current)) {
setCount((current) => current + 1)
}
})
return () => connection.close()
}, [tenantId])refオブジェクトはレンダー間で同一性が保たれ、currentを変更しても再レンダリングは発生しません。そのため、最新のハンドラー、タイマーID、それ自体が表示されない外部APIハンドルなど、命令型の値にはrefが適しています。コストとなるのは手動同期です。依存関係linterはfilterRef.currentが正しく更新されていることを証明できず、同期用Effectが欠落していると古い挙動が知らぬ間に再発します。単に再レンダリングを避けるためだけに表示用ステートをrefに移行してはならず、ドキュメントに記載された初期化パターンを除いて、レンダリング中にrefを読み書きしてはなりません。
ステップ7:useCallbackとメモ化を本来の役割にとどめる。
useCallbackは、依存関係のいずれかが変更されるまで関数の同一性をキャッシュします。これは、メモ化された子コンポーネントや外部APIが同一性を重視する場合に役立ちます。それ単体で最新の値を提供するわけではありません。
const onOrder = useCallback((order: Order) => {
if (matches(order, filter)) {
setCount((current) => current + 1)
}
}, [])このバージョンでも依然として初期フィルターがキャプチャされます。[filter]を追加すると関数はリフレッシュされますが、外部接続側でリスナーを正しく差し替える必要があります。リスナーの削除に同じ関数の同一性が必要な場合、セットアップとクリーンアップはそのレンダーにおいて同一のコールバックインスタンスを使用しなければなりません。メモ化は同一性に関する問いに答えるものであり、同期のライフタイムに関する問いに答えるものではありません。
ステップ8:非同期の順序制御を個別に解決する。
最新のフィルターがリクエストを開始すると仮定します。"all"のリクエストが、より新しい"paid"のリクエストの後に解決され、その結果を上書きしてしまう可能性があります。最新のフィルターを持つコールバックであっても、その古いリクエストの完了を防ぐことはできません。Effectは、サポートされている場合はAbortControllerを使用して不要になった処理をアボートするか、各リクエストに単調増加する世代(generation)を関連付けて最新の世代のみをコミットすべきです。
また、クリーンアップは対称的である必要があります。
- そのセットアップで作成された特定の接続を正確に切断する。
- そのセットアップで登録された特定のリスナーを正確に削除する。
- インターバルとタイムアウトをクリアする。
- 保留中の非同期処理をアボートまたは無効化する。
- クリーンアップ後に遅れて届いたコールバックを無害にする。
開発環境専用のStrict Modeによる「セットアップ→クリーンアップ→セットアップ」のシーケンスは、ここでの有用な検証材料です。接続が重複している場合は、クリーンアップが不完全であることを示しています。Strict Modeが古いクロージャを引き起こした証拠ではありません。
ステップ9:一度に1つの次元を変更して動作を検証する。
| テスト | 期待される結果 |
|---|---|
| Reactが再レンダリングする前に一致する注文を3回発行する | カウントが3増加する |
filterを変更後、注文を発行する | リスナーが再接続なしで新しいフィルターを適用する |
tenantIdを変更する | 古い接続が1回切断され、新しいテナントが1回接続される |
| クリーンアップ後に古い接続からイベントを発行する | 現在のUIが変化しない |
| 2つのリクエストが逆の順序で解決する | 最新のリクエストのみがコミットされる |
| 開発環境のStrict Modeで実行する | セットアップとクリーンアップが対称性を保ち、重複リスナーが発生しない |
| Hookの依存関係lintルールを再度有効にする | 抑制された、または説明のつかない依存関係の警告が残っていない |
本番環境では、接続のセットアップおよびクリーンアップの回数、アクティブな接続ID、テナントの切り替え、遅延イベントの破棄、アボートされたリクエストを追跡してください。単にコールバックの最新性を診断するためだけに、プライベートな注文のペイロードをログに出力してはなりません。
質の高い模範解答
「Reactのレンダーモデルから説明します。すべてのレンダーはpropsとstateのスナップショットを取得し、そのレンダー内で作成された関数はそのスナップショットをクロージャとして保持します。このEffectの依存配列は空であるため、外部接続は初回レンダー時のコールバックを保持し続けます。したがって、最初のテナント、フィルター、カウントを参照し続けます。
依存配列を変更する前に、各値を分類します。tenantIdは外部リソースを選択するため依存配列に含めるべきであり、変更時には古い接続を切断して新しい接続を開く必要があります。countは次の状態を導出するためだけに使用されているため、setCount(current => current + 1)を呼び出します。これにより、バースト的な更新でもReactのキューにある状態から正しく合成されます。最新のfilterはEffectが保持する注文リスナーが発火した際に必要ですが、接続を再起動すべきではありません。このアプリケーションはReact 19.2を使用しているため、フィルタリングロジックをuseEffectEventに配置し、tenantIdのみに依存するEffectからそのEffect Eventを登録します。
依存配列は同期を記述するものであるため、exhaustive-depsを抑制しません。また、初期クロージャが保持されてしまうため、最新性の修正としてuseCallback([])を使用することも避けます。古いReactバージョンでは、filterから同期されるrefが互換性のためのフォールバックになり得ますが、それは手動管理であり依存関係の静的解析からは見えなくなります。
最後に、連続インクリメント、再接続なしのフィルター変更、正確に1回のクリーンアップとセットアップを伴うテナント変更、切断された接続から届くイベント、開発環境のStrict Modeをテストします。コールバックがリクエストを発行する場合は、クロージャの最新性だけでは順不同な結果を防げないため、不要なリクエストのアボートまたはバージョニングも行います。」
よくある間違い
- すべてのクロージャをバグと呼ぶ → コールバックがレンダーのスナップショットをキャプチャするのは正常な動作です → 保持されているコールバックと意図した同期処理との間の不一致を特定してください。
exhaustive-depsを抑制する → リアクティブな読み取りを使用しているにもかかわらず、それが重要ではないとコード上で偽ることになります → 依存配列がEffectを忠実に記述するようにコードを変更してください。- すべてのstateの値を依存配列に追加する → 更新のたびに高コストな購読を再作成することで最新性を回復しようとしてしまいます → リソースの同一性とコールバックでの最新値の読み取りを分離してください。
useCallback(fn, [])を使用する → 安定した同一性により初回のクロージャも固定されてしまいます →useCallbackに正しい依存関係を指定するか、求められるセマンティクスに合ったツールを使用してください。- stateをrefに置き換える → refの書き込みは再レンダリングをトリガーせず、表示されるUIと乖離する可能性があります → 表示用データはstateに保持し、refは命令型の可変データに限定してください。
useEffectEventを使って本物の依存関係を隠す → 外部リソースを変更すべきときにEffectが反応しなくなります → リソースを決定づける値は依存配列に残してください。- フィルターを修正しても
setCount(count + 1)のままにする → バーストイベントが発生した際にキャプチャされたカウントで上書きされてしまいます → 前の状態から導出される状態には関数型アップデータを使用してください。 - クリーンアップとリクエストの順序制御を無視する → リークしたリスナーや遅れて届いたレスポンスが画面を破壊し続けます → ライフサイクルに従って、すべての操作を削除、終了、アボート、またはバージョニングしてください。
フォローアップ質問と回答
フォローアップ1:クリックハンドラー内のsetTimeoutが古いstateの値を参照するのはなぜですか?
タイムアウトのコールバックは、クリックが発生したレンダーに属しているためです。状態を更新すると新しいレンダーがスケジュールされますが、そのハンドラーのローカルな状態変数が変更されるわけではありません。遅延アクションがクリックされた時点の値を報告する仕様であれば、そのスナップショットは正しい挙動です。最新のコミットされた値を使用する必要がある場合は、Effect関連のコードであればEffect Eventを使い、命令型コールバックであれば慎重に保守されたrefを使用して、その要件を明示的にモデル化します。
フォローアップ2:filterをEffectの依存配列に入れてはいけないのですか?
それが正しいのは、フィルターの変更によって外部システムを再同期すべき場合のみです。サーバー側の購読自体がフィルター固有のものである場合は、依存配列に含めて再接続してください。このシナリオでは、テナントのストリームは同一であり、フィルターはローカルなイベント処理ポリシーであるため、再接続を行うと不適切なライフタイムになってしまいます。選択する前にプロダクトとAPIの前提条件を明示してください。
フォローアップ3:useEffectEventは通常のイベントハンドラーを置き換えるものですか?
いいえ。ボタンのクリックなどは通常、レンダリング中に作成されるイベントハンドラーによって処理されます。Effect Eventは、タイマーや購読リスナーなど、Effectまたはそれに接続されたコードから呼び出される非リアクティブなロジックのためのものです。それを使用するEffectに対してローカルに保つべきであり、汎用的なコールバック受け渡しメカニズムにしてはなりません。
フォローアップ4:関数型アップデータですべての古いクロージャを解決できますか?
いいえ。解決できるのは、同一のstateの以前の値から導出される状態遷移のみです。props、別のstate変数、または外部の設定を最新にすることはできません。この例では、カウントのインクリメントは修正できますが、テナント接続やフィルターの読み取りは修正できません。
フォローアップ5:stateよりもrefが好ましいのはどのような場合ですか?
レンダー間で値を保持する必要があり、その変更によってUIを再レンダリングする必要がなく、かつ命令型で値が使用される場合(タイマーID、DOMノード、接続ハンドル、最新値を保持する互換性セルなど)にrefを使用します。レンダリング結果がその値に依存している場合は、stateを使用してください。refを使用すると値の最新性を保つ責任が開発者に移るため、同期ポイントをドキュメント化しテストしてください。
フォローアップ6:Strict Modeはこの問題のデバッグにどのように役立ちますか?
開発環境のStrict Modeは、Effectに対してセットアップとクリーンアップの追加サイクルを実行します。2つのリスナーや接続が残っている場合、クリーンアップが不完全であるか、誤ったコールバック同一性を使用しています。クリーンアップが正しければ、追加サイクル後もアクティブな購読は1つだけ残ります。古いスナップショット自体は、どちらのモードでも通常のJavaScriptクロージャのセマンティクスに従います。
フォローアップ7:クリーンアップと新規接続の間にイベントが発火した場合はどうなりますか?
外部システムの配信保証を定義する必要があります。クライアントには、再開可能なカーソル、シーケンス番号、リプレイウィンドウ、またはスナップショットとそれに続くストリームが必要になる場合があります。Reactの依存関係の変更はローカルのライフタイムを管理できますが、サーバーの再接続をまたいだ損失のない配信を保証することはできません。これはプロトコル側の要件であり、個別にテストする必要があります。
フォローアップ8:古いfetchが新しい結果を上書きするのを防ぐにはどうすればよいですか?
APIがキャンセルをサポートしている場合は、Effectのクリーンアップ中に不要になったリクエストをアボートします。サポートされていない場合は、各リクエストに世代を割り当て、完了した世代が依然として最新である場合にのみ状態を更新します。最新値を保持するref単体ではネットワーク処理はキャンセルされず、順序保証の手段として提示すべきではありません。