ASP.NET Core 8でモーダル(ポップアップ)内のフォームをjQuery + AJAXで送信していると、バリデーションエラー後の再送信で「1回しか押していないのに複数件INSERTされる」現象に遭遇することがあります。多くの場合、サーバー処理よりもフロント側のsubmitイベントが多重登録されているのが原因です。
起きていること:バリデーションNG後の再送信で複数レコードが保存される
典型的な流れは次のとおりです。
- モーダル内に部分ビュー(PartialView)でフォームを表示する
- フォーム送信はjQueryのsubmitイベントでフックし、AJAXでPOSTする
- 必須項目が空のまま送信すると、サーバー側は
!ModelState.IsValidになり、バリデーションエラー付きのHTML(部分ビュー)を返す - クライアント側は返ってきたHTMLでフォーム領域を差し替え、同じポップアップ内で入力を続けられるようにする
- その後、入力を埋めて再送信すると、1回の送信のはずが複数回POSTされ、複数件INSERTされる
しかも厄介なのが、「最初に失敗した回数」=「最終的に保存される件数」のように見える点です。これは偶然ではなく、後述する“イベントハンドラの増殖”が起きているサインです。
原因:initializeFormScripts()の多重実行でsubmitイベントが増殖している
結論から言うと、原因はsubmitイベントが二重・三重に登録されていることです。
よくある実装では、部分ビュー差し替え後に次のような初期化関数を再度呼びます。
function initializeFormScripts() {
$('#addEmployeeForm').on('submit', function (e) {
e.preventDefault();
// AJAXで送信...
});
}
そして、バリデーションNGのときにフォームHTMLを差し替えたあとで、もう一度初期化します。
success: function (response) {
if (!response.success) {
$('#addEmployeeFormContainer').html(response.html);
initializeFormScripts(); // ← ここで失敗するたびに増える
}
}
「差し替えたのに、なぜ増えるの?」の落とし穴
「フォームを差し替えているなら、古いフォーム要素は消えるはず。だからハンドラも消えるのでは?」と思いがちです。ここで重要なのは、実際に差し替えている範囲です。
例えば、次のようにformタグ自体は残したまま、中身(container)だけを差し替える構造だとします。
<form id="addEmployeeForm" method="post" action="/Employee/Add">
<div id="addEmployeeFormContainer">
<!-- ここだけ部分ビューで差し替え -->
</div>
<button type="submit">保存</button>
</form>
この場合、#addEmployeeForm(フォーム要素)はDOMに残り続けます。つまり、initializeFormScripts()を呼ぶたびに同じフォームにハンドラが追加され、送信時にAJAXが登録数だけ発火します。
| 操作 | 登録されているsubmitハンドラ数 | 最終送信時のPOST回数 | 結果 |
|---|---|---|---|
| 最初の表示 | 1 | – | まだ保存しない |
| 1回目:必須未入力で送信 → 失敗 | 2(初期化が再実行される) | – | エラー表示に切り替わる |
| 2回目:また失敗 | 3 | – | さらに増える |
| 3回目:入力を埋めて成功 | 3 | 3回 | 同じINSERTが3回走る |
「失敗回数と保存件数が連動する」のは、こうした構造で説明できます。
対策の基本方針:submitハンドラを重複登録しない
直し方はシンプルで、“登録を増やさない”ことです。現場で効くのは大きく次の2パターンです。
| 対策 | 何をする? | 向いているケース | メリット | 注意点 |
|---|---|---|---|---|
| 対策A:off()してからon() | 登録前に既存ハンドラを外して付け直す | 「初期化関数を呼び直す」設計を変えにくい | 最小変更で直りやすい | offの範囲が広いと他のsubmit処理まで外す |
| 対策B:イベント委譲 | document(または固定の親要素)に1回だけ登録 | フォームHTMLを頻繁に差し替える(PartialView更新) | 再初期化が不要になり、再発しにくい | 登録場所が増えると追跡しにくいので名前空間が重要 |
対策A:登録前にoff()で外してからon()(二重登録防止)
まずは最も分かりやすい方法です。ポイントはoffで外すときに“自分が付けたハンドラだけ”を外すことです。
jQueryはイベントに名前空間を付けられるので、submit.addEmployeeのように付けておくと安全です。
function initializeFormScripts() {
const $form = $('#addEmployeeForm');
// 自分が付けたsubmitハンドラだけ外す(他のsubmit処理を壊しにくい)
$form.off('submit.addEmployee').on('submit.addEmployee', function (e) {
e.preventDefault();
const form = $(this);
$.ajax({
url: form.attr('action'),
type: form.attr('method') || 'POST',
data: form.serialize(),
success: function (response) {
if (response.success) {
closePopup();
showSuccessMessage();
} else {
$('#addEmployeeFormContainer').html(response.html);
// ここで呼び直す設計なら、off→onのおかげで増殖しない
initializeFormScripts();
}
}
});
});
}
off(‘submit’)は避けたほうがいい理由
off('submit')のようにイベント名だけで外すと、他ライブラリ(例:jquery.validate / unobtrusive validation)が内部で登録しているsubmitハンドラまで外す可能性があります。「直ったけど、いつの間にかクライアント側バリデーションが効かない」のような副作用が出ることがあるため、名前空間で絞り込むのが無難です。
対策B:イベント委譲(おすすめ:フォーム差し替えでも再登録不要)
フォームを部分ビューで差し替える設計なら、こちらが最も安定します。フォームが何回差し替わっても、イベント登録は1回だけで済むからです。
$(document).on('submit.addEmployee', '#addEmployeeForm', function (e) {
e.preventDefault();
const $form = $(this);
$.ajax({
url: $form.attr('action'),
type: $form.attr('method') || 'POST',
data: $form.serialize(),
success: function (response) {
if (response.success) {
closePopup();
showSuccessMessage();
} else {
$('#addEmployeeFormContainer').html(response.html);
// 再登録は不要(常にdocument側で受ける)
}
}
});
});
「documentに付けるのは広すぎる」と感じる場合は、モーダルの固定ルート要素(例:#employeeModal)に付けてもOKです。重要なのは、差し替えられない親要素に1回だけ付けることです。
$('#employeeModal').on('submit.addEmployee', '#addEmployeeForm', function (e) {
// 同様にAJAX送信
});
切り分け:同一送信でPOSTが複数回飛んでいるか確認する
「本当に複数回POSTされているのか?」を先に確認すると、原因特定が一気に早くなります。
| 確認場所 | 見るポイント | 複数送信の典型パターン | 次にやること |
|---|---|---|---|
| ブラウザ開発者ツール(Network) | 1回のクリックで同じPOSTが複数並ぶか | 同じURLに連続で2回以上POST | イベント多重登録を疑う |
| サーバー側ブレークポイント | Actionに何回入るか | 1回の操作でActionが複数回ヒット | フロント側のハンドラ重複が濃厚 |
| サーバーログ(ILogger) | 同一ユーザー・同一タイミングのPOST回数 | 数百ms〜数秒内に同じログが複数 | 相関IDを付けて追跡する |
Chromeでイベント登録数をざっくり確認する小技
原因が疑わしいときは、Consoleで次のように確認できる場合があります(Chrome限定・状況により表示できないことがあります)。
getEventListeners(document.querySelector('#addEmployeeForm'))
submitが複数入っていたら、ほぼ確定で多重登録です。
再発防止のための設計改善ポイント
対策A/Bでまず止血したうえで、次の点も整えると同じ種類のバグが起きにくくなります。
レスポンス形式を統一する(HTMLとJSONの混在を避ける)
1つのActionで「成功時はJSON」「失敗時はPartialView(HTML)」のように返すと、フロント側の判定が複雑になりがちです。おすすめは次のどちらかに寄せることです。
| 方針 | サーバーの返し方 | フロントの受け方 | 特徴 |
|---|---|---|---|
| 常にJSONで返す | { success, html } | successを見てhtml差し替え | 分岐が明確、AJAX向き |
| 常にHTMLで返す | 成功でも失敗でもPartialView | 返却HTMLをそのまま差し替え | シンプルだが成功判定の設計が必要 |
現場では「常にJSONで返す」方式が扱いやすいことが多いです。成功時は画面を閉じるだけ、失敗時はhtmlを差し替えるだけ、という形に揃えられます。
ViewModel→Entityのマッピングを明確にする(「どこから来た変数か不明」をなくす)
保存処理で、どこから来たか分からない変数(グローバルに見えるEntityなど)をそのままAddしていると、バグの温床になります。基本はViewModelを受け取り、必要な項目だけEntityに詰め替えて保存します。
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddEmployee(AddEmployeeViewModel vm)
{
if (!ModelState.IsValid)
{
// 例:失敗時もJSONで返す(html生成はプロジェクト方針に合わせる)
return Json(new { success = false, html = RenderPartial("_AddEmployeeForm", vm) });
}
var employee = new Employee
{
Name = vm.Name,
DepartmentId = vm.DepartmentId,
Email = vm.Email
};
_context.Employees.Add(employee);
await _context.SaveChangesAsync();
return Json(new { success = true });
}
※RenderPartialは「PartialViewを文字列化する」ためのヘルパー例です。プロジェクトで既に仕組みがない場合は、失敗時は単純にreturn PartialView(...)にして、成功時もHTML返却に寄せる方が実装コストは低いです。
未使用の引数・未使用のプロパティを放置しない
メソッド引数(例:Departmentsなど)がコード上で未使用のままだと、将来の改修で「本当は必要だったのか?」「どこでセットされるのか?」が追いにくくなります。
- 不要なら削除する(バインド対象から外す)
- 必要ならViewModelに含め、Viewで確実に送信される形にする
- サーバー側で受け取るだけでなく、バリデーションや保存に使うならユースケースをコメントで残す
検証ロジックは二重化しない(DataAnnotationsとIValidatableObjectの整理)
[Required]などのDataAnnotationsと、IValidatableObject.Validate()で同じ項目を二重に検証していると、エラーメッセージの出方が揺れたり、修正漏れが起きたりします。おすすめは次のいずれかです。
- 単純な必須・範囲・文字数はDataAnnotationsに寄せる
- 項目間の相関チェック(AのときB必須など)は
IValidatableObjectに寄せる - 複雑なルールはカスタムValidationAttributeを作って集約する
クライアント側バリデーションを使う場合は「再パース」を忘れない
ASP.NET CoreのDataAnnotations + jQuery Validate(Unobtrusive Validation)を使っている場合、部分ビュー差し替え後に次のような再パースが必要になることがあります。
$.validator.unobtrusive.parse('#addEmployeeForm');
ただし、これも「差し替えのたびに初期化関数を呼ぶ」構造だと、今回のような多重登録につながりやすいです。バリデーションの再パースと、submitイベント登録は分離して考えるのが安全です。
追加の安全策:二重送信を“起こりにくく”する
イベント多重登録を潰したうえで、さらに堅牢にするための定番テクニックをまとめます。複数人で触る画面ほど、こうした“保険”が効きます。
送信中はボタンを無効化する
$(document).on('submit.addEmployee', '#addEmployeeForm', function (e) {
e.preventDefault();
const $form = $(this);
const $btn = $form.find('button[type="submit"]');
if ($btn.prop('disabled')) return; // 多重クリック対策
$btn.prop('disabled', true);
$.ajax({
url: $form.attr('action'),
type: $form.attr('method') || 'POST',
data: $form.serialize(),
success: function (response) {
if (response.success) {
closePopup();
} else {
$('#addEmployeeFormContainer').html(response.html);
}
},
complete: function () {
$btn.prop('disabled', false);
}
});
});
Anti-forgeryトークンをAJAXに載せる
モーダルのフォームでも通常のフォームでも、ASP.NET CoreではCSRF対策としてAnti-forgeryトークンを使うのが基本です。フォーム内に@Html.AntiForgeryToken()を入れている場合、AJAX送信ではhiddenから取り出してヘッダーに載せるのが簡単です(既定のヘッダー名はRequestVerificationToken)。
const token = $('#addEmployeeForm input[name="__RequestVerificationToken"]').val();
$.ajax({
url: $form.attr('action'),
type: 'POST',
data: $form.serialize(),
headers: { 'RequestVerificationToken': token }
});
サーバー側でも「重複保存されない」ガードを用意する
フロントのイベント多重登録は直せますが、通信リトライや二重クリック、ブラウザの戻るなどで重複POSTが起きる可能性はゼロになりません。重要データなら、サーバー側でも次のようなガードを検討してください。
- 一意制約(例:社員番号、メールアドレスなど重複してはいけないキーにUnique Index)
- Idempotency Key(リクエストに一意キーを付け、同じキーは1回だけ処理)
- 保存前の存在チェック(ただし競合に弱いので、最終的にはDB制約が強い)
よくある質問と落とし穴
「initializeFormScripts()を呼ばないと動かない」は間違い?
間違いではありません。ただし、役割を分けるのがコツです。
- イベント登録(submitなど)はページ読み込み時に1回だけ行う
- 部分ビュー差し替え後に必要な処理(例:unobtrusive.parse、日付ピッカーの再初期化など)は差し替え後にだけ行う
この2つが同じ関数に混ざると、今回のようにイベントだけが増殖しやすくなります。
「off→onにしたのに直らない」場合に見るべきポイント
- 登録先が一致しているか:
$('#addEmployeeForm').on(...)に対して、$(document).off(...)しても外れません - 名前空間が一致しているか:
submit.addEmployeeで付けたなら、外す側も同じ文字列にする - 同じ処理が別ファイルでも登録されていないか:モーダル表示のたびにscriptを読み込む構成だと二重化しやすい
まとめ:複数INSERTは「サーバーのバグ」より「フロントの多重登録」を疑う
- バリデーションNG後の再送信で保存件数が増えるなら、まず同一操作でPOSTが複数回飛んでいないかNetworkで確認する
- 原因の多くは、部分ビュー差し替え後に初期化を繰り返してsubmitイベントが増殖していること
- 最短で直すならoff()→on()(名前空間付き)、再発しにくいのはイベント委譲
- レスポンス形式の統一、ViewModel→Entityの明確化、検証ロジックの整理、送信中のボタン無効化なども合わせると堅牢になる

コメント