フォームの二重送信を防ぐ方法
ユーザーがチェックアウトフォームの「送信」ボタンをクリックします。すぐには何も起こりません。ユーザーは再度クリックします。その結果、2つの注文、2つの課金、そして1人のフラストレーションを抱えた顧客が生まれます。
このシナリオは、本番環境のアプリケーションで常に発生しています。ユーザーは習慣的にダブルクリックし、ネットワークは予測不可能に遅延し、焦った指が繰り返しタップします。その結果:重複したデータベースエントリ、失敗したトランザクション、そしてサポートチケットです。
この記事では、クライアント側の戦略とサーバー側の冪等性を組み合わせた多層防御を使用して、実際の条件下でも堅牢な送信フローを構築し、フォームの二重送信を防ぐ方法について説明します。
重要なポイント
- 送信ボタンを無効化するだけでは不十分です—JavaScriptが失敗する可能性があり、ユーザーはキーボードから送信でき、ボットはフロントエンドを完全にバイパスします。
- data属性で送信状態を追跡し、クリックとキーボード送信の両方を防ぎます。
- エラー時には必ずフォームコントロールを再度有効化し、ユーザーがページを更新せずに再試行できるようにします。
- サーバー側の冪等性トークンが決定的な保護手段であり、重複したリクエストが重複した副作用を生み出さないことを保証します。
- 効果的な保護には多層防御が必要です:クライアント側の対策はUXを改善し、サーバー側の重複排除は正確性を保証します。
単層防御が失敗する理由
送信ボタンを無効化するだけに頼るのは単純明快に見えます。しかし、次のようなシナリオを考えてみてください:
- JavaScriptの読み込みまたは実行が失敗する
- ハンドラーが実行される前にユーザーがキーボード(Enterキー)で送信する
- ネットワークリクエストがタイムアウトし、ユーザーがページを更新する
- ボットやスクリプトがフロントエンドを完全にバイパスする
クライアント側の対策はユーザーエクスペリエンスを向上させますが、保護を保証することはできません。リクエストはブラウザ、curl、モバイルアプリ、または悪意のある攻撃者など、どこからでも発生する可能性があるため、サーバー側の検証は不可欠です。
効果的なフォーム送信のベストプラクティスには、多層防御が必要です。
Webフォームで重複リクエストを回避するクライアント側の戦略
送信状態の追跡
単にボタンを無効化するのではなく、フォームが現在送信中かどうかを追跡します:
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', (e) => {
if (form.dataset.submitting === 'true') {
e.preventDefault();
return;
}
form.dataset.submitting = 'true';
});
});
このアプローチは、ボタンのクリックとキーボードからの送信の両方を処理します。最近のフレームワークは、この状態を自動的に提供することが多く、ReactのuseFormStatusフックや他のライブラリの類似パターンは、直接使用できる保留状態を公開しています。
明確な視覚的フィードバックの提供
ユーザーは、自分のアクションが登録されたという確信が持てないときに再送信します。不確実性を明確さに置き換えます:
- 送信ボタンを無効化し、かつローディングインジケーターを表示する
- ボタンのテキストを「送信中…」に変更するか、スピナーを表示する
- フィールドの変更を防ぐために、フォーム全体を無効化することを検討する
form[data-submitting="true"] button[type="submit"] {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
エラーの適切な処理
よくある間違い:送信が失敗した後、ボタンを永続的に無効化してしまうこと。エラーが発生したときは、必ずフォームコントロールを再度有効化し、ユーザーが再試行できるようにします:
async function handleSubmit(form) {
form.dataset.submitting = 'true';
try {
await submitForm(form);
} catch (error) {
form.dataset.submitting = 'false'; // 再試行を許可
showError(error.message);
}
}
連続送信のデバウンス
非同期バリデーションや複雑な処理を使用するフォームの場合、デバウンスは、焦ったユーザーや押し続けられたEnterキーによる連続送信を防ぎます:
let submitTimeout;
form.addEventListener('submit', (e) => {
if (submitTimeout) {
e.preventDefault();
return;
}
submitTimeout = setTimeout(() => {
submitTimeout = null;
}, 2000);
});
Discover how at OpenReplay.com.
サーバー側の冪等性:決定的な保護層
クライアント側の対策は重複送信を減らします。サーバー側の冪等性はその影響を排除します。
トークンを使用した冪等なフォーム送信
フォームをレンダリングする際に一意のトークンを生成します。それを隠しフィールドとして含めます:
<input type="hidden" name="idempotency_key" value="abc123-unique-token">
サーバー側で、このトークンを既に処理したかどうかを確認します。処理済みの場合は、キャッシュされたレスポンスを返します。未処理の場合は、リクエストを処理してトークンを保存します。
このパターン—リクエストの重複排除と呼ばれることもあります—は、同一のリクエストが複数回到着しても、1つだけが副作用を生み出すことを保証します。
重複APIリクエストの防止における重要性
Stripeのような決済プロセッサーが、まさにこの理由で冪等性キーを要求します。ネットワーク障害、リトライ、タイムアウトにより、同じリクエストが複数回到着する可能性があります。冪等性がなければ、顧客に2回課金してしまう可能性があります。
同じ原則は、レコードの作成、メールの送信、ワークフローのトリガーなど、状態を変更するあらゆる操作に適用されます。
統合的な実装
効果的な保護は、これらの戦略を階層化します:
- フロントエンド: 状態を追跡し、コントロールを無効化し、フィードバックを表示する
- フロントエンド: エラー時に再度有効化し、連続送信をデバウンスする
- バックエンド: 冪等性トークンを検証し、リクエストを重複排除する
- バックエンド: 重複送信に対して一貫したレスポンスを返す
単一の技術では不十分です。クライアント側の処理はUXを改善し、サーバー側の冪等性は正確性を保証します。
まとめ
フォームの二重送信を確実に防ぐには、即座の視覚的フィードバックとサーバー側のリクエスト重複排除を組み合わせます。クライアント側の対策はUXの改善として扱い、セキュリティコントロールとしては扱わないでください。リクエストが複数回到着することを前提として送信フローを設計してください—実際のネットワーク条件下では、そうなるからです。多層防御は任意ではありません:ユーザー、ネットワーク、エッジケースがあなたに不利に働くとき、唯一耐えられるアプローチです。
よくある質問
いいえ。ボタンの無効化は、JavaScriptが正しく読み込まれて実行される場合にのみ機能します。ユーザーはEnterキーで送信でき、ボットやスクリプトはフロントエンドを完全にバイパスします。重複したリクエストが重複した副作用を生み出さないことを保証するために、サーバー側の冪等性が必要です。
冪等性キーは、フォームがレンダリングされるときに生成される一意のトークンで、送信と共に送信されます。サーバーは、そのトークンを既に処理したかどうかを確認します。処理済みの場合は、リクエストを再度処理する代わりに、以前のレスポンスを返します。これにより、重複したレコード、課金、その他の副作用を防ぎます。
両方を使用してください。data属性による送信状態の追跡は、単一のリクエストライフサイクル中の繰り返しクリックとキーボード送信を防ぎます。デバウンスは、連続的な再送信をキャッチする時間ベースのクールダウンを追加します。両方を組み合わせることで、どちらか一方だけよりも多くのエッジケースをカバーできます。
エラーが発生したときに送信状態フラグをリセットします。dataset.submittingのようなdata属性を使用している場合は、catchブロックでfalseに戻します。これにより、フォームコントロールが再度有効化され、ユーザーはページを更新せずに問題を修正して再度送信できます。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.