FULLALLGOL 技術ノート

持ち越した取得結果は、もっともらしい別物の値に化ける

2026-08-31

あるダッシュボードの月次トレンド表示について、利用者からこう報告された。

対象を切り替えても、数字が変わらない。

正確には「変わらない」のではなく、前の選択の行が、新しい選択の値として描かれていた
表示されている数字は、切り替え前の対象の実データだ。だから桁も自然で、
画面を見ただけでは間違いだと分からない。

列名が共通だから、事故が隠れる

この画面は、選択(対象カテゴリ・期間・担当者)ごとに別のクエリを投げる。
カテゴリごとにテーブルが分かれており、それらは兄弟なので
contract gained points といった列名が完全に共通だった。

片方のテーブルの行をもう片方の配列に入れても、型エラーは起きない。
集計関数はそのまま動き、合計も自然な桁で出る。

列名が違っていれば、集計は 0 になって即座に気づけたはずだ。
共通の列名は再利用にとっては美点だが、取り違えを無音にするという副作用を持つ。

3 つ重なって、初めて表に出る

この不具合は、次の 3 つが揃ったときだけ現れる。どれか 1 つでも欠けていれば見えない。

  1. 取得結果に鍵が無い ── useState<Row[]> で行の配列だけを持ち、

その配列が「どの選択の結果か」を誰も知らない

  1. 切替時に前の結果をリセットしない ── 新しいクエリが解決するまで、古い配列が生き残る
  2. 呼び出し側が読み込み状態を捨てている ── 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 フレーム」なので、
通常の「解決後に正しい値が出る」テストでは絶対に捕まらない。明示的にこう書く。

そして例によって、欠陥を戻して赤を見るまで信じない
鍵の照合行を消して、このテストが落ちることを確認してから採用した。

まとめ