Reactで、フォームの二重送信を防ぐ方法
送信ボタンを続けて押すと、同じ内容のリクエストが2回送られ、注文が2件できてしまうことがあります。画面の側では、送信中はボタンを disabled にすると、ダブルクリックや Enter の連打を止められます。押した瞬間に確実に止めたいときは、送信中かどうかを useRef で持ちます。画面の対策をすり抜けた送信に備えて、サーバー側で同じ送信を1回として扱う方法も組み合わせます。
筆者は検証環境で、注文を送るだけの小さなフォームを書き方ごとに作り、Chromium を Playwright で操作して、サーバーが何回リクエストを受け取ったかを数えました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 153 を Playwright 1.63.0 で操作しました。サーバーは同じマシンの中で動かした Node.js の HTTP サーバー(http://127.0.0.1)で、注文のリクエストを受け取ると 500ms 待ってから応答します。Firefox と Safari、スマートフォンのブラウザ、Server Functions を使う構成は試していません。
書き方と押し方ごとの結果
先に、サーバーが受け取ったリクエストの回数をまとめます。押し方は4つ試しました。「同じタスクの中で2回」は、button.click() をスクリプトから続けて2回呼んだものです。ブラウザがイベントを処理するひと区切り(タスク)の中で2回目のクリックが起きるので、再レンダーが間に入りません。人のクリックやキー操作では起きにくい、いちばん厳しい条件です。
| 書き方 | ダブルクリック | 100ms おきに3回クリック | 入力欄で Enter を2回 | 同じタスクの中で2回 |
|---|---|---|---|---|
| 対策なし | 2回 | 3回 | 2回 | 2回 |
| state で送信中を持ち、ボタンを無効にする | 1回 | 1回 | 1回 | 2回 |
| ref で送信中を持つ | 1回 | 1回 | 1回 | 1回 |
| form の action(対策なし) | 2回 | 3回 | 2回 | 2回 |
form の action と isPending |
1回 | 1回 | 1回 | 2回 |
form の action と useFormStatus |
1回 | 1回 | 1回 | 2回 |
| Idempotency-Key を付ける(画面の対策なし) | 2回(注文は1件) | 3回(注文は1件) | 2回(注文は1件) | 2回(注文は1件) |
人が操作する押し方なら、ボタンを無効にするだけで1回に収まりました。画面側で止める書き方(state、ref、isPending、useFormStatus)では、送信が終わったあとにもう一度押すと、2回目の送信は普通に通りました。

対策がないと、押した回数だけ送られる
対策なしのフォームは、次のように書きました。postOrder は、fetch で /api/orders に POST する関数です。
import { useState, type FormEvent } from 'react';
import { postOrder } from './api';
// 対策なし:押されるたびに送る
export function NoGuard() {
const [message, setMessage] = useState('');
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const item = String(new FormData(e.currentTarget).get('item'));
const order = await postOrder(item);
setMessage(`注文番号 ${order.orderId}`);
}
return (
<form onSubmit={handleSubmit}>
<input name="item" defaultValue="猫じゃらし" />
<button type="submit">注文する</button>
<p>{message}</p>
</form>
);
}
ダブルクリックすると、サーバーは2回受け取り、注文も2件できました。2回目は1回目から数ms後に届いていて、1回目の応答を待たずに送られています。
ダブルクリック:受け取った POST 2回(1回目からの時刻 0 / 5 ms)、作った注文 2件、表示「注文番号 2」
100ms おきに3回クリック:受け取った POST 3回(1回目からの時刻 0 / 105 / 210 ms)、作った注文 3件、表示「注文番号 3」
入力欄で Enter を2回:受け取った POST 2回(1回目からの時刻 0 / 4 ms)、作った注文 2件、表示「注文番号 2」
送信中は、ボタンを disabled にする
いちばん基本の対策は、送信中かどうかを state で持ち、送信中はボタンに disabled を付けることです。終わったら finally で元に戻すので、失敗したときも、もう一度押せます。
import { useState, type FormEvent } from 'react';
import { postOrder } from './api';
// state で送信中を持ち、ボタンを無効にする
export function StateGuard() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState('');
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (isSubmitting) return;
setIsSubmitting(true);
try {
const item = String(new FormData(e.currentTarget).get('item'));
const order = await postOrder(item);
setMessage(`注文番号 ${order.orderId}`);
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input name="item" defaultValue="猫じゃらし" />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '送信中…' : '注文する'}
</button>
<p>{message}</p>
</form>
);
}
ダブルクリック、100ms おきの3回クリック、Enter の2回は、どれも1回だけ送られました。押してから 100ms 後のボタンは disabled=true で、文字は「送信中…」になっていました。
このとき止めているのは、ボタンの disabled です。submit イベントの回数を数えたところ、ダブルクリックでも Enter の2回でも1回でした。2回目の操作は、handleSubmit が呼ばれる前に止まっています。
StateGuard/ダブルクリック:submit イベント 1回、受け取った POST 1回
StateGuard/入力欄で Enter を2回:submit イベント 1回、受け取った POST 1回
MDN の disabled 属性の説明によると、無効にした要素は、多くのブラウザでクリックなどのイベントを受け取りません。Enter で送信する操作については、HTML の仕様の「暗黙の送信」に、フォームの最初の送信ボタンが無効でないときに、そのボタンへのクリックとして扱うと書かれています。検証でも、ボタンが無効なあいだの Enter では submit イベントが起きませんでした。
if (isSubmitting) return は、同じタスクの中の2回目を止められない
一方で、スクリプトから button.click() を続けて2回呼ぶと、2回とも送られました。submit イベントも2回起きています。
StateGuard/同じタスクの中で click() を2回:submit イベント 2回、受け取った POST 2回
React の「スナップショットとしての state」の説明のとおり、setIsSubmitting(true) は、いま手元にある isSubmitting を書き換えず、再レンダーを予約するだけです。再レンダーが起きる前に2回目が来ると、ボタンはまだ無効になっておらず、isSubmitting も false のままです。そのため、if (isSubmitting) return では止まりません。
押した瞬間に止めるなら、ref で送信中を持つ
送信中かどうかの判定を useRef に任せると、同じタスクの中の2回目も止められます。useRef のリファレンスにあるとおり、ref はただの JavaScript のオブジェクトで、current を書き換えるとその場で変わります。再レンダーは起きないので、画面に出す「送信中…」は今までどおり state で持ちます。
import { useRef, useState, type FormEvent } from 'react';
import { postOrder } from './api';
// ref で「送信中」をすぐに記録し、state は表示にだけ使う
export function RefGuard() {
const submittingRef = useRef(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState('');
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (submittingRef.current) return;
submittingRef.current = true;
setIsSubmitting(true);
try {
const item = String(new FormData(e.currentTarget).get('item'));
const order = await postOrder(item);
setMessage(`注文番号 ${order.orderId}`);
} finally {
submittingRef.current = false;
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input name="item" defaultValue="猫じゃらし" />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '送信中…' : '注文する'}
</button>
<p>{message}</p>
</form>
);
}
同じタスクの中で2回呼んだ場合も、submit イベントは2回起きましたが、送られたのは1回でした。2回目は handleSubmit の先頭で止まっています。
RefGuard/同じタスクの中で click() を2回:submit イベント 2回、受け取った POST 1回
ref の値は画面に出ないので、ボタンの disabled と見た目は、state の isSubmitting で切り替えます。ref は判定、state は表示と、役目を分けておくと混乱しません。
form の action を使うときは、isPending でボタンを無効にする
React 19 の useActionState と <form action> を使う場合も、それだけでは二重送信は止まりません。ボタンをそのままにしたフォームでは、ダブルクリックで2回送られました。
ダブルクリック:受け取った POST 2回(1回目からの時刻 0 / 512 ms)、作った注文 2件、表示「注文番号 2」
100ms おきに3回クリック:受け取った POST 3回(1回目からの時刻 0 / 505 / 1008 ms)、作った注文 3件、表示「注文番号 3」
違うのは、2回目が届いた時刻です。onSubmit で書いたフォームでは数ms後でしたが、こちらは約 500ms 後、つまり1回目の応答が返ってから送られています。useActionState のリファレンスに、続けて呼ばれたアクションは順番待ちにして1つずつ実行すると書かれているとおりです。同時には送られませんが、回数は減りません。
止めるには、useActionState の3つ目の戻り値 isPending を、ボタンの disabled に渡します。
import { useActionState } from 'react';
import { postOrder } from './api';
// useActionState の isPending でボタンを無効にする
export function ActionPending() {
const [message, formAction, isPending] = useActionState(
async (_prev: string, formData: FormData) => {
const order = await postOrder(String(formData.get('item')));
return `注文番号 ${order.orderId}`;
},
'',
);
return (
<form action={formAction}>
<input name="item" defaultValue="猫じゃらし" />
<button type="submit" disabled={isPending}>
{isPending ? '送信中…' : '注文する'}
</button>
<p>{message}</p>
</form>
);
}
送信ボタンを別の部品にしたいときは、useFormStatus の pending を使っても同じ結果になりました。ボタンの部品は、<form> の中に置きます。
import { useFormStatus } from 'react-dom';
// form の中に置いたボタンが、useFormStatus で送信中を読んで自分を無効にする
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '送信中…' : '注文する'}
</button>
);
}
どちらも、人が操作する3つの押し方では1回だけ送られました。同じタスクの中で2回呼んだ場合は、2回目が順番待ちになり、1回目の応答が返った約 500ms 後に送られました。form の action を使う書き方で、この2回目まで止める方法は試していません。どうしても1回に絞りたいときは、次のサーバー側の対策を組み合わせます。
サーバー側では、同じキーの送信を1回として扱う
画面の対策は、同じタスクの中の2回目のように、すり抜ける場合があります。最後の守りとして、送信ごとに一意なキーを付け、サーバーが同じキーの2回目以降を新しい注文として扱わないようにします。
キーを送るヘッダーとして、IETF の作業部会が Idempotency-Key ヘッダーの仕様案(Internet-Draft)をまとめています。仕様案では、キーの値に UUID のようなランダムな値を勧めていて、値は文字列としてダブルクォートで囲みます。検証では、画面を開いたときに crypto.randomUUID() でキーを1つ作り、ヘッダーに付けて送りました。
// api.ts の一部
const headers: Record<string, string> = { 'content-type': 'application/json' };
// 仕様案では、値は Structured Fields の String(ダブルクォートで囲む)
if (idempotencyKey) headers['Idempotency-Key'] = `"${idempotencyKey}"`;
import { useRef, useState, type FormEvent } from 'react';
import { postOrder } from './api';
// 画面側の対策を外したうえで、同じ注文に同じキーを付けて送る
export function IdempotencyKey() {
const keyRef = useRef(crypto.randomUUID());
const [message, setMessage] = useState('');
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const item = String(new FormData(e.currentTarget).get('item'));
const order = await postOrder(item, keyRef.current);
setMessage(`注文番号 ${order.orderId}`);
}
return (
<form onSubmit={handleSubmit}>
<input name="item" defaultValue="猫じゃらし" />
<button type="submit">注文する</button>
<p>{message}</p>
</form>
);
}
検証用のサーバーは、キーごとに最初の応答を覚えておき、同じキーが来たら注文を作らずに同じ応答を返します。画面の対策を外しているので、リクエストは押した回数だけ届きますが、作られた注文はどの押し方でも1件でした。
ダブルクリック:受け取った POST 2回(1回目からの時刻 0 / 2 ms)、作った注文 1件、表示「注文番号 1」
100ms おきに3回クリック:受け取った POST 3回(1回目からの時刻 0 / 108 / 215 ms)、作った注文 1件、表示「注文番号 1」
この例では、画面を開いたときに作ったキーをずっと使います。1回目の注文が終わってからもう一度押すと、サーバーはリクエストを受け取りましたが、新しい注文は作らず、1件目と同じ「注文番号 1」を返しました。続けて別の注文を受け付ける画面では、注文が終わったらキーを作り直します。
検証用のサーバーは、どのリクエストも 500ms 待ってからキーを確かめる、簡単な作りです。ほぼ同時に届いた2件が1件にまとまったのは、1件目が先にキーを記録し終えていたからです。処理にかかる時間がばらつく実際のサーバーでは、この作りだと2件目のほうが先にキーを確かめてしまい、重複を防げないことがあります。仕様案では、1回目の処理中に同じキーのリクエストが届いたら、409 Conflict を返すよう勧めています。実際のサーバーでは、処理中のキーも記録して、この場合に備えます。
二重送信を防ぐときのポイント
送信中はボタンに disabled を付けると、ダブルクリックや Enter の連打による二重送信を止められます。form の action を使うなら、isPending か useFormStatus の pending をそのまま disabled に渡します。onSubmit で書くなら、送信中かどうかの判定を useRef で持つと、再レンダーを待たずに2回目を止められます。そのうえで、サーバー側では Idempotency-Key のようなキーで同じ送信を1回として扱うと、画面の対策をすり抜けた送信でも注文が重複しません。





