React 二重送信に強いAPIを作る③ 同じ操作をReactから再送する
Reactから問い合わせを送り、通信が失敗した場合には同じ操作を再送します。
重要なのは、再送のたびに新しいキーを作らないことです。送信する本文も最初の内容に固定し、結果が分からない間に別の内容へ変えないようにします。
操作キーと本文をひとまとめにする
まず、通信処理を作ります。
inquiryApi.tsexport 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.tsximport { 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は既存の認証状態から取得します。未認証ならフォームを表示しません。
次は同時実行と運用上の限界を確認するで、複数接続のテストと保存期間を考えます。