背景として、面接の会話記録を扱うプロダクトの記事を AceRound のブログ で公開しています。この記事はその転載ではありません。ここでは、回答生成のような非同期 UI で起きる「古いレスポンスによる上書き」を、TypeScript の状態機械として切り出します。
非同期の回答生成 UI では、ユーザーが質問を直してすぐ再実行することがあります。最初のリクエストが遅く、二つ目が速いとき、到着順は開始順と一致しません。
この問題は「ローディング中はボタンを押せない」にすると隠せます。しかし、キャンセル、再試行、入力の変更を許す UI ではすぐに破綻します。解決したいのは待たせることではなく、現在の要求だけが表示を更新できるという契約です。
この記事では次の二点を実装します。
- UI の状態を有限個にし、遷移を一か所で判定する
- 各要求に
requestIdを付け、古い成功・失敗を無視する
到着順が逆転する場面
例えば、最初に「自己紹介を作る」と送信した直後、利用者が条件を足して二回目を送信したとします。
start(id=1)start(id=2)result(id=1)result(id=2)
id=1 の結果を画面に出すと、利用者が最後に依頼した内容ではありません。HTTP が壊れているわけではなく、独立した二つの非同期処理では普通に起こる順序です。
AbortController で古い通信を中断するのは有用です。ただし、すでに完了直前だった要求や、中断を扱わない下流処理まで止められるとは限りません。画面へ反映する直前に識別子を照合する判定は、キャンセルとは別に残しておく価値があります。
状態を boolean ではなく直和型で表す
isLoading、answer、error を別々の変数にすると、次のような矛盾した組み合わせを作れてしまいます。
-
isLoading === trueなのに前回のanswerが残っている -
errorとanswerが同時にある - どの要求に対する結果なのか分からない
ここでは一つの状態を直和型にします。各状態に必要な値だけを持たせます。
type State =
| { kind: 'idle' }
| { kind: 'loading'; requestId: number; question: string }
| { kind: 'ready'; requestId: number; answer: string }
| { kind: 'failed'; requestId: number; message: string };
type Event =
| { kind: 'start'; requestId: number; question: string }
| { kind: 'succeeded'; requestId: number; answer: string }
| { kind: 'failed'; requestId: number; message: string }
| { kind: 'reset' };
type Transition =
| { ok: true; state: State }
| { ok: false; state: State; reason: string };
ready に error を持たせないため、「成功結果と失敗理由が同時に表示される」状態は型の形では表現できません。
遷移を一か所に集める
start は現在の状態にかかわらず新しい要求を開始します。成功・失敗のイベントは、loading かつ同じ requestId のときだけ受け入れます。
function transition(state: State, event: Event): Transition {
if (event.kind === 'reset') {
return { ok: true, state: { kind: 'idle' } };
}
if (event.kind === 'start') {
return {
ok: true,
state: {
kind: 'loading',
requestId: event.requestId,
question: event.question,
},
};
}
if (state.kind !== 'loading') {
return {
ok: false,
state,
reason: 'response_without_active_request',
};
}
if (state.requestId !== event.requestId) {
return { ok: false, state, reason: 'stale_response' };
}
if (event.kind === 'succeeded') {
return {
ok: true,
state: {
kind: 'ready',
requestId: event.requestId,
answer: event.answer,
},
};
}
return {
ok: true,
state: {
kind: 'failed',
requestId: event.requestId,
message: event.message,
},
};
}
ここで重要なのは、古いイベントを例外扱いにしないことです。通信が並行していれば古い結果が届くことは正常です。ok: false は UI を壊すべき障害ではなく、「この結果には表示を変える権利がない」という判断です。必要なら reason を開発時の計測に使えます。
非同期処理からイベントを送る
状態を React の useState、Zustand、Redux などどこに置くかは本質ではありません。更新の入口を transition に統一します。
let nextRequestId = 0;
let state: State = { kind: 'idle' };
async function generateAnswer(question: string) {
const requestId = ++nextRequestId;
state = transition(state, {
kind: 'start',
requestId,
question,
}).state;
try {
const answer = await callAnswerApi(question);
const result = transition(state, {
kind: 'succeeded',
requestId,
answer,
});
state = result.state;
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown_error';
const result = transition(state, {
kind: 'failed',
requestId,
message,
});
state = result.state;
}
}
実際の UI では、state の読み書きをストアの更新関数に置き換えます。大事なのは callAnswerApi() を開始した時点の requestId を、成功・失敗のどちらにも渡すことです。
また、requestId はこの画面インスタンス内で単調増加すれば十分です。サーバーの永続 ID やユーザー ID を流用すると、責務が混ざります。これは表示の鮮度を決めるためのローカルな世代番号です。
テストで順序逆転を固定する
非同期のタイミングを実際の setTimeout に任せるテストは不安定です。状態機械だけを同期的に呼べば、問題の順番をそのままテストできます。
import { describe, expect, test } from 'bun:test';
describe('transition', () => {
test('最後に開始したリクエストだけが結果を反映する', () => {
const first = transition(
{ kind: 'idle' },
{ kind: 'start', requestId: 1, question: 'A' },
);
const second = transition(
first.state,
{ kind: 'start', requestId: 2, question: 'B' },
);
const stale = transition(
second.state,
{ kind: 'succeeded', requestId: 1, answer: 'old' },
);
expect(stale).toMatchObject({
ok: false,
reason: 'stale_response',
});
expect(stale.state).toEqual({
kind: 'loading',
requestId: 2,
question: 'B',
});
});
test('アクティブな要求だけが ready へ遷移する', () => {
const loading: State = {
kind: 'loading',
requestId: 8,
question: '設計の工夫は?',
};
expect(
transition(loading, {
kind: 'succeeded',
requestId: 8,
answer: '答え',
}),
).toEqual({
ok: true,
state: { kind: 'ready', requestId: 8, answer: '答え' },
});
});
});
手元では上の状態遷移に対して、古い成功、現在の成功、アクティブな要求がない失敗の三ケースを bun test で確認しました。
この設計で決めていないこと
この状態機械は一つの回答欄を対象にしています。画面に複数の質問カードがあり、それぞれ同時に生成するなら、カード ID ごとに State とカウンタを持ちます。一つのグローバルな requestId だけで全カードを管理すると、別のカードの要求まで古いものとして扱ってしまいます。
サーバー側で同じ生成を二重実行させない話も別です。そこで必要なのは idempotency key やジョブ ID です。ここで守っているのは、あくまでクライアント表示における「最後に意図した操作」の整合性です。
非同期 UI では、レスポンスを受け取った事実と、そのレスポンスを表示してよい事実は別です。二つを requestId で分けておくと、再実行や失敗時の挙動を後から足しても、判定場所が増えません。