チャットUIのシステム図。履歴状態として「LOADING」「SUCCESS(空履歴)」「ERROR(取得失敗)」「IDLE」が示され、世代カウンタで古い応答を破棄する仕組みや、競合対策、送信メッセージのロック状態が描かれています。開発者らしきキャラクターが「SEND MESSAGE」ボタンを指しており、記事で解説するチャット履歴の四状態管理と非同期処理の競合対策の概念を図解しています。

はじめに

チャットUIで過去の履歴を取得するとき、取得に失敗したのに「履歴はありません」と同じ空の画面が表示されてしまうことがあります。
利用者は失敗に気づけないまま新しいメッセージを送ってしまい、あとから履歴と食い違う、という困った状況になります。

この記事では、履歴取得を idle / loading / success / error の4状態で管理し、失敗時には再取得ボタンを出して、取得が終わるまで送信を止める構成を紹介します。
あわせて、会話や利用者が切り替わった後に遅れて届いた応答が画面に混入しないよう、世代カウンタで古い非同期結果を捨てる方法も扱います。

こんな人におすすめ

  • isLoading の boolean だけで取得状態を管理していて、空と失敗の区別がつかない方
  • 画面遷移やセッション切替のあとに、古いレスポンスが表示されてしまう経験がある方
  • ボタン連打による二重リクエストを確実に防ぎたい方
  • 読込中・エラー表示をスクリーンリーダーにも伝わるようにしたい方

「0件」と「取得失敗」は別の状態にする

isLoading: boolean だけでは、空配列が返ってきた場合と失敗した場合の両方が「ロード完了・メッセージ0件」に見えてしまいます。
ステータスを列挙型にすると、UI側の分岐が素直に書けます。

type HistoryStatus = "idle" | "loading" | "success" | "error";

const [historyStatus, setHistoryStatus] = useState<HistoryStatus>("idle");

表示は状態ごとに出し分けます。
失敗と正常な0件は、文言も role も変えるのがポイントです。

{historyStatus === "loading" && <p role="status">履歴を読み込み中です…</p>}

{historyStatus === "error" && (
  <div role="alert">
    <p>履歴を取得できませんでした。送信するには履歴を再取得してください。</p>
    <button type="button" onClick={onRetryHistory}>履歴を再取得</button>
  </div>
)}

{historyStatus === "success" && messages.length === 0 && (
  <p role="status">履歴はありません。</p>
)}

読込中と空は role="status"、失敗は role="alert" にしておくと、スクリーンリーダーにも状態の違いが伝わります。

世代カウンタと ref で非同期処理を守る

利用者やセッションが切り替わった直後に、前の会話の履歴レスポンスが遅れて返ってくることがあります。
そのまま setState すると、別の会話の内容が現在の画面に混ざります。

対策として、切替のたびに増える整数を useRef に持たせ、await のあとで世代が変わっていたら何も触らずに戻ります。
cancelled フラグでも似たことはできますが、切替イベントが複数種類ある場合は、世代カウンタのほうが扱いやすいと感じました。

const generationRef = useRef(0);
const historyPendingRef = useRef(false);

// セッションや利用者が変わったら世代を進める
useEffect(() => {
  generationRef.current += 1;
  historyPendingRef.current = false;
  setHistoryStatus("idle");
}, [sessionId, userId]);

const retryHistory = useCallback(async () => {
  if (!sessionId || !userId || historyPendingRef.current) return;
  const generation = generationRef.current;
  historyPendingRef.current = true;
  setHistoryStatus("loading");
  try {
    const response = await fetchHistory(sessionId);
    if (generation !== generationRef.current) return; // 古い結果は捨てる
    if (response.success) {
      loadHistoryMessages(response);
      setHistoryStatus("success");
    } else {
      setHistoryStatus("error");
    }
  } catch {
    if (generation === generationRef.current) setHistoryStatus("error");
  } finally {
    if (generation === generationRef.current) {
      historyPendingRef.current = false;
    }
  }
}, [sessionId, userId, fetchHistory, loadHistoryMessages]);

この関数は、初回取得と手動の再取得で共用できます。
切替後に返ってきた応答は state に反映されません。

多重取得のガードは state ではなく ref で

二重呼び出しの防止に historyStatus === "loading" を使いたくなりますが、state は再レンダー後にしか更新されません。
ボタン連打など、再レンダー前に連続で呼ばれる場合はすり抜けます。
historyPendingRef.current を同期的に見れば、確実に弾けます。

取得中は送信だけを止める

取得が終わる前に送信できてしまうと、冒頭の食い違いが再発します。
一方で、待っている間に入力内容を失わせたくもありません。
そこで、入力不可の disabled と、送信のみ不可の sendDisabled を分けます。

const sendDisabled = historyStatus !== "success";

<textarea value={draft} onChange={onChange} disabled={disabled} />
<button type="submit" disabled={disabled || sendDisabled}>送信</button>

下書きは自由に編集でき、履歴が揃った時点で送信できるようになります。

履歴のマージは既存メッセージを優先する

取得完了時に setMessages(history) と上書きすると、取得中に追加されたメッセージが消えます。
関数形式の更新で既存のIDと照合し、重複を除いて先頭に足します。

setMessages((current) => [
  ...historyMessages.filter((m) => !current.some((e) => e.id === m.id)),
  ...current,
]);

つまづきやすいポイント

  • 既存テストへの波及: 送信をガードすると、送信系のテストすべてで「履歴が解決するまで待つ」処理が必要になります。waitFor(() => expect(historyStatus).toBe("success")) のような待機を追加する前提で、修正範囲を見込んでおきましょう。
  • 世代の更新漏れ: セッション切替だけでなく、利用者の切替やログアウトでも世代を進めないと、古い応答が通り抜けます。切替の種類を洗い出してから実装するのが安全です。
  • finally での pending 解除: 世代が変わったあとの古い処理が historyPendingRef を false に戻すと、新しい取得のガードを壊します。上のコードのように、世代が一致するときだけ解除してください。
  • 失敗と0件の文言の混同: 見た目が似ていると、結局利用者は区別できません。文言と role の両方を分けることをおすすめします。

まとめ

履歴取得を4状態で管理すると、「0件」と「失敗」を明確に分けられ、再取得や送信ガードも自然に組み込めます。
世代カウンタで古い非同期結果を捨て、多重取得は ref で防ぐことで、切替や連打に強い構成になります。
disabled と sendDisabled の分離、既存IDを優先するマージ、role の使い分けまで含めて、利用者が状況を把握できるUIを目指してみてください。