isLoading 一つでは実際の画面を表せない。更新中でも以前のデータは有用な場合があり、成功した空の一覧と通信失敗も別物だ。オフラインでキャッシュがある場合とない場合も表示を分けたい。
データとリクエストを分離する
snapshot、lastSyncedAt、requestState、error を保持し、表示状態をそこから導く。スナップショットがなければ骨格表示または再試行できるエラーを出す。データがあるなら更新中も本文を残し、失敗は非ブロッキングな通知にする。真の空状態は信頼できる成功応答からのみ確定する。
| データ | 通信 | 画面の表示 |
|---|---|---|
| なし | 初回読込 | 骨格表示と戻る操作 |
| なし | 失敗 | エラーと再試行 |
| あり | 更新中 | 本文を残して進捗を表示 |
| あり | 失敗 | 本文と同期時刻、控えめなエラー |
| 空の一覧 | 成功 | 本当の空状態と作成導線 |
判定規則を画面から切り離す
RN なら selector、Flutter なら ViewModel に deriveScreenState(snapshot, request) のような純粋な規則を置く。初回表示、引っ張って更新、前景復帰、通知からの更新は同じデータ入口を使い、更新理由だけを区別する。
低速回線、連続更新、アカウント切り替え、オフライン起動を検証する。失敗で既存の一覧を消さず、前の利用者のデータは新しいアカウントの画面に一瞬も表示しない。
空状態には根拠が要る
メッセージ一覧を考える。初回表示でキャッシュがなければ initialLoading であり、正常な応答で 0 件と確認できて初めて confirmedEmpty になる。以前に 20 件を確認済みなら、引き下げ更新中も一覧を読める refreshingWithData にする。更新失敗時は staleWithError とし、20 件と最終成功時刻を残す。これを loading | success | error にまとめると、更新のたびに内容を消すか、失敗を空一覧と誤認する。
データ層には {accountId, items, lastSuccessAt, revision, pending} のような事実を保持し、表示状態をそこから導く。各コンポーネントで独立した真偽値を増やさない。items=[] は配列が空というだけで、真の空状態には「現在のアカウントで取得に成功した」という根拠が必要だ。アカウント切替時は旧データを隔離してから新しいキャッシュを読み、旧ユーザーの内容を一瞬でも見せない。
| イベント | 保持するデータ | 画面の表示 |
|---|---|---|
| 初回取得が失敗 | なし | エラー画面と再試行 |
| キャッシュありで更新失敗 | 確認済み一覧 | 画面内エラー、同期時刻、再試行 |
| 正常に 0 件を取得 | 現アカウントの空スナップショット | 明確な空状態 |
| アカウント切替 | 新アカウントのキャッシュのみ | スケルトンまたは新しい一覧 |
画面より状態遷移を試す
reducer や状態機械のテストで、成功(20件) → 更新 → タイムアウト は 20 件を保持し、成功(20件) → 成功(0件) で初めて空状態になることを確認する。Aを取得 → Bへ切替 → Aの応答 では A の結果を捨てる。UI テストでは再試行、読み上げ、古いデータの表示も見る。静止画ではなく利用者が経験する変化を検証できる。
