Reactのフォーム送信で二重実行を防ぐ責務をレビューする観点
はじめに
Reactのフォーム送信は、見た目より事故が起きやすい。
入力値を集めてAPIへ送るだけに見えても、レビューでは次のような状態を確認する必要がある。
- 送信ボタンを連打したらどうなるか
- API応答待ちの間に入力値を変えたらどうなるか
- バリデーション失敗と通信失敗を分けて扱っているか
- 送信成功後に同じpayloadを再送できてしまわないか
この記事では、Reactフォームの「送信責務」をレビューする観点を整理する。
ポイントは、ボタンをdisabledにすることだけではない。どの単位を送信中として扱い、どこで再送を止めるかである。
まず止めたい実装
次のコードは、送信時にAPIを呼び、成功したら完了メッセージを出している。
export function ProfileForm() {
const [name, setName] = useState("");
const [message, setMessage] = useState("");
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const response = await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (response.ok) {
setMessage("保存しました");
}
@Reviewer送信中状態がないため、連打やEnter連続入力で同じ内容が複数回送信されます。サーバー側の冪等性に依存するなら、その前提を明示してください。 }
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={event => setName(event.target.value)} />
<button type="submit">保存</button>
{message && <p>{message}</p>}
</form>
);
}この実装はデモなら成立する。
しかし実務では、同じ保存リクエストが複数回走る可能性がある。
プロフィール更新ならまだ影響は小さいかもしれない。
注文作成、チケット発行、招待メール送信、ポイント利用のような処理では、そのまま障害になる。
レビューで見るべき本質
フォーム送信のレビューでは、UIイベントではなく業務上の実行単位を見る。
次の問いに答えられない実装は危ない。
- 1回の送信とは何を指すのか
- 送信中に同じ操作を受け付けるのか
- 失敗時に再送してよいのか
- 成功後にフォームを残すのか、閉じるのか
- サーバー側に冪等キーがあるのか
isSubmitting を入れるだけでは不十分なことがある。
ただし、まずは送信中状態が明示されているかが第一の確認線になる。
レビュー観点1:pending状態が同期的に立つか
送信中フラグは、API呼び出し前に立てる必要がある。
export function ProfileForm() {
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (isSubmitting) {
return;
}
setIsSubmitting(true);
setErrorMessage("");
try {
const response = await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!response.ok) {
throw new Error("failed to save profile");
}
} catch {
setErrorMessage("保存できませんでした");
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={event => setName(event.target.value)}
disabled={isSubmitting}
/>
<button type="submit" disabled={isSubmitting}>
保存
</button>
{errorMessage && <p role="alert">{errorMessage}</p>}
</form>
);
}ここでレビューしたいのは、disabled の有無だけではない。
isSubmitting がAPI前に立ち、成功・失敗の両方で解除されることを確認する。
@Reviewer: 送信中状態はありますが、API呼び出し後に設定されているため、短時間の連打を防げません。送信処理の入口で同期的にpendingへ遷移させてください。レビュー観点2:入力値のスナップショットを送っているか
送信中に入力変更を許す場合、送信した値と画面上の値がずれる。
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setIsSubmitting(true);
await saveProfile({ name });
setIsSubmitting(false);
}このコードでは、await 中に name が変わる可能性がある。
JavaScriptのクロージャとして送信payloadは呼び出し時点の値になるが、ユーザーから見ると「今画面にある値が保存された」と誤解しやすい。
レビューでは方針を明確にする。
| 方針 | 確認すること |
|---|---|
| 送信中は入力を止める | inputをdisabledまたはreadOnlyにする |
| 入力変更を許す | 送信中の値と現在値の差をUIで破綻させない |
| 自動保存にする | 送信単位とキャンセル単位を別途設計する |
明示されていない場合は、送信中に入力を止める方がレビューしやすい。
レビュー観点3:クライアントの二重送信対策だけに依存していないか
React側でボタンをdisabledにしても、二重実行を完全には防げない。
- ネットワーク再送
- ブラウザの戻る・再実行
- 別タブからの同時送信
- APIクライアントや自動テストからの直接実行
業務上二重登録が致命的なら、サーバー側の冪等性が必要になる。
const requestId = crypto.randomUUID();
await fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": requestId,
},
body: JSON.stringify({ itemId, quantity }),
});フロントエンドレビューでも、ここは指摘対象にしてよい。
UIで連打を止める責務と、業務上の重複作成を防ぐ責務は別だからだ。
@Reviewer: UI上のdisabledだけでは二重作成の保証にはなりません。注文作成のように重複が業務影響を持つ処理なので、API側の冪等キーまたは重複判定の有無を確認したいです。レビュー観点4:バリデーション失敗と通信失敗を混ぜていないか
送信失敗をすべて同じメッセージにすると、ユーザーも実装者も次の行動を判断できない。
if (!response.ok) {
setErrorMessage("保存できませんでした");
}レビューでは少なくとも次を分けたい。
- 入力値が不正
- 認証・認可が切れている
- 競合更新が起きた
- 通信やサーバー障害で失敗した
これらはUIの戻し方が違う。
入力値不正ならフォームに残す。認可エラーなら操作自体を隠すか無効化する。競合更新なら再読み込みや差分確認が必要になる。
改善の方向性
レビューしやすいフォーム送信は、次のように状態遷移が読める。
async function submitProfile() {
if (submission.status === "submitting") {
return;
}
const payload = buildProfilePayload(formValues);
const validationError = validateProfile(payload);
if (validationError) {
setSubmission({ status: "invalid", error: validationError });
return;
}
setSubmission({ status: "submitting" });
try {
const savedProfile = await saveProfile(payload);
setProfile(savedProfile);
setSubmission({ status: "succeeded" });
} catch (error) {
setSubmission({ status: "failed", error: toSubmitError(error) });
}
}ここでは、送信前検証、送信中、成功、失敗が状態として分かれている。
レビューアーは制御フローを追いやすく、失敗時のUIも設計しやすい。
レビューコメント例
@Reviewer: `onSubmit` が多重に実行された場合の制御が見えません。API呼び出し前に送信中状態へ遷移し、同一フォームの再送を止める構造にしてください。@Reviewer: この処理は登録系の副作用を持つため、フロントエンドのdisabledだけでは重複作成対策として弱いです。サーバー側の冪等キーや重複判定の前提を確認したいです。まとめ
Reactフォームのレビューでは、送信ボタンの見た目よりも、送信責務の境界を見る。
- pending状態がAPI前に立つか
- 送信中の入力変更をどう扱うか
- クライアント側だけで二重登録を防ごうとしていないか
- 失敗理由ごとにUIの戻し方が分かれているか
フォーム送信は小さな実装に見える。
しかし副作用を持つ処理では、二重実行を止める設計こそレビューで確認すべき中心になる。