0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptで古い非同期レスポンスを捨てる:requestId付き状態機械とテスト

0
Posted at

背景として、面接の会話記録を扱うプロダクトの記事を AceRound のブログ で公開しています。この記事はその転載ではありません。ここでは、回答生成のような非同期 UI で起きる「古いレスポンスによる上書き」を、TypeScript の状態機械として切り出します。

非同期の回答生成 UI では、ユーザーが質問を直してすぐ再実行することがあります。最初のリクエストが遅く、二つ目が速いとき、到着順は開始順と一致しません。

この問題は「ローディング中はボタンを押せない」にすると隠せます。しかし、キャンセル、再試行、入力の変更を許す UI ではすぐに破綻します。解決したいのは待たせることではなく、現在の要求だけが表示を更新できるという契約です。

この記事では次の二点を実装します。

  • UI の状態を有限個にし、遷移を一か所で判定する
  • 各要求に requestId を付け、古い成功・失敗を無視する

requestId を持つ非同期状態機械の遷移図

到着順が逆転する場面

例えば、最初に「自己紹介を作る」と送信した直後、利用者が条件を足して二回目を送信したとします。

  1. start(id=1)
  2. start(id=2)
  3. result(id=1)
  4. result(id=2)

id=1 の結果を画面に出すと、利用者が最後に依頼した内容ではありません。HTTP が壊れているわけではなく、独立した二つの非同期処理では普通に起こる順序です。

古いレスポンスを捨てる時系列

AbortController で古い通信を中断するのは有用です。ただし、すでに完了直前だった要求や、中断を扱わない下流処理まで止められるとは限りません。画面へ反映する直前に識別子を照合する判定は、キャンセルとは別に残しておく価値があります。

状態を boolean ではなく直和型で表す

isLoadinganswererror を別々の変数にすると、次のような矛盾した組み合わせを作れてしまいます。

  • isLoading === true なのに前回の answer が残っている
  • erroranswer が同時にある
  • どの要求に対する結果なのか分からない

ここでは一つの状態を直和型にします。各状態に必要な値だけを持たせます。

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 };

readyerror を持たせないため、「成功結果と失敗理由が同時に表示される」状態は型の形では表現できません。

遷移を一か所に集める

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 で分けておくと、再実行や失敗時の挙動を後から足しても、判定場所が増えません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?