zukucode
主にWEB関連の情報を技術メモとして発信しています。

React 二重送信に強いAPIを作る③ 同じ操作をReactから再送する

Reactから問い合わせを送り、通信が失敗した場合には同じ操作を再送します。

重要なのは、再送のたびに新しいキーを作らないことです。送信する本文も最初の内容に固定し、結果が分からない間に別の内容へ変えないようにします。

  1. 操作の単位とキーを決める
  2. 受付結果をDBに保存する
  3. 同じ操作をReactから再送する(この記事)
  4. 同時実行と運用上の限界を確認する

操作キーと本文をひとまとめにする

まず、通信処理を作ります。

inquiryApi.ts
export type InquiryBody = Readonly<{ subject: string; message: string }>;
export type PendingInquiry = Readonly<{ key: string; body: InquiryBody }>;
export type ReceiptResult = { receiptId: string; receivedAt: string };

export function createPending(body: InquiryBody): PendingInquiry {
  return Object.freeze({ key: crypto.randomUUID(), body: Object.freeze({ ...body }) });
}

export async function sendInquiry(pending: PendingInquiry): Promise<ReceiptResult> {
  const csrf = await fetch('/api/inquiries/csrf', {
    credentials: 'same-origin', cache: 'no-store',
  });
  if (!csrf.ok) throw new Error('認証と通信状態を確認してください。');
  const { token }: { token: string } = await csrf.json();
  const response = await fetch('/api/inquiries', {
    method: 'POST', credentials: 'same-origin',
    headers: {
      'Content-Type': 'application/json', 'X-CSRF-TOKEN': token,
      'Idempotency-Key': pending.key,
    },
    body: JSON.stringify(pending.body),
  });
  if (response.status === 409)
    throw new Error('操作キーと内容が一致しません。新しいキーで送り直さず、受付状況を確認してください。');
  if (response.status === 401)
    throw new Error('ログイン状態を確認してください。');
  if (!response.ok)
    throw new Error('受付結果を確認できませんでした。入力や通信状態を確認してください。');
  const result: unknown = await response.json();
  if (!result || typeof result !== 'object' ||
      !('receiptId' in result) || typeof result.receiptId !== 'string' ||
      !('receivedAt' in result) || typeof result.receivedAt !== 'string')
    throw new Error('受付結果を読み取れませんでした。');
  return { receiptId: result.receiptId, receivedAt: result.receivedAt };
}

createPendingは、1回の操作を始めるときに呼びます。sendInquiryの中ではキーを作らないので、同じオブジェクトで再送すれば同じキーと本文を送れます。

Object.freezeで、送信中の本文を誤って書き換えにくくしています。今回の本文は文字列だけなので、この浅い固定で十分です。入れ子のオブジェクトを持つ場合は、その扱いも決めてください。

crypto.randomUUID()はセキュアコンテキストで使います。MDNのrandomUUIDの説明も参照してください。

送信した内容を、結果が分かるまで保持する

InquiryForm.tsx
import { useRef, useState, type FormEvent } from 'react';
import { createPending, sendInquiry, type PendingInquiry, type ReceiptResult } from './inquiryApi';

export function InquiryForm() {
  const [subject, setSubject] = useState('');
  const [message, setMessage] = useState('');
  const [pending, setPending] = useState<PendingInquiry | null>(null);
  const [result, setResult] = useState<ReceiptResult | null>(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');
  const running = useRef(false);
  const operation = useRef<PendingInquiry | null>(null);

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (running.current || result) return;
    running.current = true;
    setBusy(true); setError('');
    try {
      if (!operation.current) {
        const body = {
          subject: subject.trim(),
          message: message.replace(/\r\n?/g, '\n').trim(),
        };
        if (!body.subject || body.subject.length > 100 ||
            !body.message || body.message.length > 2000)
          throw new Error('件名は1〜100文字、本文は1〜2000文字で入力してください。');
        operation.current = createPending(body);
        setPending(operation.current);
      }
      setResult(await sendInquiry(operation.current));
    } catch (e) {
      setError(e instanceof Error ? e.message : '結果を確認できませんでした。');
    } finally {
      running.current = false; setBusy(false);
    }
  }
  return <section>
    <form onSubmit={submit}>
      <fieldset disabled={busy || pending !== null || result !== null}>
        <legend>問い合わせ内容</legend>
        <label>件名<input value={subject} maxLength={100}
          onChange={e => setSubject(e.target.value)} /></label>
        <label>本文<textarea value={message} maxLength={2000}
          onChange={e => setMessage(e.target.value)} /></label>
      </fieldset>
      {!result && <button type="submit" disabled={busy}>
        {busy ? '確認中…' : pending ? '同じ内容で受付結果を確認する' : '送信する'}
      </button>}
    </form>
    <p role="status">{error}</p>
    {pending && !result && <p>送信内容を保持しています。この画面を閉じる前に受付結果を確認してください。</p>}
    {result && <>
      <p role="status">受け付けました。受付番号:{result.receiptId}</p>
      <button type="button" onClick={() => {
        operation.current = null; setPending(null); setResult(null);
        setSubject(''); setMessage(''); setError('');
      }}>別の問い合わせを作成する</button>
    </>}
  </section>;
}

operationのrefには送信する操作を、pendingのstateには表示を切り替えるための同じ操作を入れています。キーを作ってrefに設定してから通信を開始するため、再試行でも同じ値を使えます。

runningは同じコンポーネントからの重複実行を防ぎます。再描画前に続けて送信イベントが来ても、2本の通信を始めないためです。

再送は結果の確認にもなる

通信失敗後のボタンは「同じ内容で受付結果を確認する」としています。

この操作で新しい受付が作られる場合もあります。最初のリクエストが届いていなければ、その再送が初回の登録になるためです。一方、すでに保存されていれば、元の受付番号が返ります。

どちらの場合も、利用者が始めた同じ問い合わせは1件に収まります。入力内容を変更せず、同じキーを使うことが前提です。

AbortControllerなどでブラウザー側の待機を中断しても、サーバーの処理が必ず取り消されたとは限りません。中断した場合も、次の試行でキーを変えないようにします。

結果不明のまま新規送信へ戻さない

結果が不明な間は、入力欄を無効にしています。新しいキーで送信し直すボタンも表示しません。

409が返った場合は、キーと内容の管理に不整合がある可能性があります。新しいキーへ変えれば送れるとしても、先の問い合わせが存在していないことにはなりません。受付一覧やサポートなど、別の確認経路で状況を確認します。

この小さな例では、操作生成後のAPIエラーはすべて内容を保持したままにします。入力エラーから編集へ戻す導線を追加する場合も、以前に結果不明の送信がなかったかを考慮してください。「今回400だった」だけでは、過去の送信も未保存だったとは断定できません。

受付番号を受け取って初めて、「別の問い合わせを作成する」を表示します。ここで次の操作へ進んだ場合だけ、新しいキーが生成されます。

画面を閉じた後の復元は別の設計

このコードはReactのメモリー内にキーと本文を保持します。再読み込みやタブを閉じた場合、復元できません。

そのため、結果を確認するまで画面を閉じない案内を出しています。実際の運用で再読み込み後の復旧も必要なら、利用者と結び付いた下書き・操作記録の保存や、受付一覧からの確認を追加します。

問い合わせ本文には個人情報が含まれる場合があります。復元のために、無条件でlocalStorageへ保存する処理は加えていません。保存先、保存期間、ログアウト時の削除を決めてから導入してください。

認証中の利用者が変わったら、このフォームも破棄します。別の利用者として、前の問い合わせを送信してはいけません。認証状態を持つ親で利用者IDをkeyにする方法があります。

認証済みの親から表示する例
<InquiryForm key={currentUser.id} />

currentUserは既存の認証状態から取得します。未認証ならフォームを表示しません。

次は同時実行と運用上の限界を確認するで、複数接続のテストと保存期間を考えます。


関連記事