持ち越した取得結果は、もっともらしい別物の値に化ける
あるダッシュボードの月次トレンド表示について、利用者からこう報告された。
対象を切り替えても、数字が変わらない。
正確には「変わらない」のではなく、前の選択の行が、新しい選択の値として描かれていた。
表示されている数字は、切り替え前の対象の実データだ。だから桁も自然で、
画面を見ただけでは間違いだと分からない。
列名が共通だから、事故が隠れる
この画面は、選択(対象カテゴリ・期間・担当者)ごとに別のクエリを投げる。
カテゴリごとにテーブルが分かれており、それらは兄弟なのでcontract gained points といった列名が完全に共通だった。
片方のテーブルの行をもう片方の配列に入れても、型エラーは起きない。
集計関数はそのまま動き、合計も自然な桁で出る。
列名が違っていれば、集計は 0 になって即座に気づけたはずだ。
共通の列名は再利用にとっては美点だが、取り違えを無音にするという副作用を持つ。
3 つ重なって、初めて表に出る
この不具合は、次の 3 つが揃ったときだけ現れる。どれか 1 つでも欠けていれば見えない。
- 取得結果に鍵が無い ──
useState<Row[]>で行の配列だけを持ち、
その配列が「どの選択の結果か」を誰も知らない
- 切替時に前の結果をリセットしない ── 新しいクエリが解決するまで、古い配列が生き残る
- 呼び出し側が読み込み状態を捨てている ──
const { points } = useX()のように
loading を受け取らないので、読み込み中の値が確定値として描かれる
3 が特に効く。フックは正直に loading: true を返していたのに、
使う側がその戻り値を分解代入で捨てていた。フックの契約は、使われ方まで見ないと守られない。
処方は「結果に鍵を持たせる」
リセットだけでは足りない。状態更新は effect の中で走るので、
キャッシュに命中した場合でも描画は 1 フレーム後になる。
その 1 フレームのあいだ、古い行が新しいパラメータで集計される。
⇒ 結果は行の配列ではなく { key, rows } で持つ。
const [state, setState] = useState<{ key: string; rows: Row[] }>({ key: '', rows: [] })
const key = `${category}:${period}:${memberId}`
// 鍵が一致しない間は「空 + loading」を返す。リセット漏れも 1 フレームの混線も、
// 鍵の照合ならまとめて塞げる
if (state.key !== key) return { rows: [], loading: true, error: null }
あわせて 2 つ。
- 失敗を握り潰さない。 取得エラーを空配列に潰すと、
「0 件」と「取れなかった」が画面上で区別できなくなる。error を別に返して書き分ける
- 同型のフックは横並びで直す。 この画面では、期間別・前年比較・個人記録・半期集計の
4 本のフックが同じ形をしていた。1 本だけ直すと、残りは同じ症状のまま残る
テストは「切替直後」を固定する
再現条件が「fetch が解決する前の 1 フレーム」なので、
通常の「解決後に正しい値が出る」テストでは絶対に捕まらない。明示的にこう書く。
- 鍵 A で解決させる
- 鍵 B に切り替える(fetch は未解決のまま)
- この瞬間に返る値が、鍵 A の値でないこと(空であり、
loadingが真であること)
そして例によって、欠陥を戻して赤を見るまで信じない。
鍵の照合行を消して、このテストが落ちることを確認してから採用した。
まとめ
- 選択ごとに投げる取得の結果は、必ず「どの鍵の結果か」を一緒に持つ
- 兄弟テーブルの共通列名は、取り違えを無音にする。型も桁も合ってしまう
- リセットだけでは 1 フレームの混線が残る。鍵の照合が要る
- 失敗は空配列に潰さず
errorで返す - 同じ形のフックが複数あるなら、見つけた 1 本だけでなく全部数えて直す