Reactで、フォームの二重送信を防ぐ方法

​​React​Playwright,TypeScript,useActionState,useFormStatus,useRef,フォーム

送信ボタンを続けて押すと、同じ内容のリクエストが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回目の送信は普通に通りました。

対策なしのフォームと、送信中にボタンを無効にするフォームのボタンを、それぞれ続けて2回クリックした様子。対策なしではサーバーが受け取った回数が2回、無効にするほうでは2回目のクリックが無効になったボタンで止まり、1回になる
続けて2回クリックしたときに、サーバーが受け取った回数(前半は対策なし、後半は送信中にボタンを無効にするフォーム)

対策がないと、押した回数だけ送られる

対策なしのフォームは、次のように書きました。postOrder は、fetch で /api/orders に POST する関数です。

tsx
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回目の応答を待たずに送られています。

text
ダブルクリック:受け取った 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 で元に戻すので、失敗したときも、もう一度押せます。

tsx
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 が呼ばれる前に止まっています。

text
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回起きています。

text
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 で持ちます。

tsx
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 の先頭で止まっています。

text
RefGuard/同じタスクの中で click() を2回:submit イベント 2回、受け取った POST 1回

ref の値は画面に出ないので、ボタンの disabled と見た目は、state の isSubmitting で切り替えます。ref は判定、state は表示と、役目を分けておくと混乱しません。

form の action を使うときは、isPending でボタンを無効にする

React 19 の useActionState と <form action> を使う場合も、それだけでは二重送信は止まりません。ボタンをそのままにしたフォームでは、ダブルクリックで2回送られました。

text
ダブルクリック:受け取った 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 に渡します。

tsx
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> の中に置きます。

tsx
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つ作り、ヘッダーに付けて送りました。

tsx
// api.ts の一部
const headers: Record<string, string> = { 'content-type': 'application/json' };
// 仕様案では、値は Structured Fields の String(ダブルクォートで囲む)
if (idempotencyKey) headers['Idempotency-Key'] = `"${idempotencyKey}"`;
tsx
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件でした。

text
ダブルクリック:受け取った 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回として扱うと、画面の対策をすり抜けた送信でも注文が重複しません。

​Posted by 86