ASP.NET Core 8のポップアップフォームで複数レコードが保存される原因と対策(jQuery AJAX submit多重登録)

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回目:入力を埋めて成功33回同じ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の明確化、検証ロジックの整理、送信中のボタン無効化なども合わせると堅牢になる

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次