0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

静的 LP の Ajax フォームで POST リクエストをどう扱うか

0
Posted at

静的なランディングページに問い合わせフォームや資料請求フォームを置く場合、fetch()POST すれば Ajax フォームは実装できます。

しかし、フォーム送信は単なる通信処理ではありません。

問い合わせ、資料請求、セミナー申込み、メールマガジン登録などのフォームでは、POST の裏側でメール送信、通知、CRM 登録、スプレッドシート記録などの副作用が発生します。

そのため、クライアントサイドでも最低限、

  • 送信中 UI
  • 二重クリック防止
  • タイムアウト
  • エラー表示
  • 自動リトライの扱い
  • 複数フォームでの共通化

を考える必要があります。

この記事では、静的 LP + Ajax フォームを前提に、クライアントサイドで対応できる範囲に絞って、POST リクエストの扱い方を整理します。

対象読者

この記事は、次のような人を対象にしています。

  • 静的なランディングページにフォームを設置したい人
  • fetch() で Ajax フォーム送信を実装している人
  • LP に複数のフォームを置く予定がある人
  • ky などの fetch ラッパーを導入すべきか迷っている人
  • フォーム送信時のタイムアウト、キャンセル、二重送信防止を整理したい人

この記事では、サーバーサイドの実装、DB での重複投稿防止、CAPTCHA、レート制限、CSRF 対策の詳細には踏み込みません。

ただし、最後に「クライアントサイドだけではできないこと」も整理します。

1. 静的 LP でも POST は副作用を持つ

まず押さえたいのは、LP フォームの POST は「データを送るだけ」ではないということです。

たとえば、問い合わせフォームの送信後には次のような処理が発生します。

  • 管理者へのメール通知
  • 自動返信メール送信
  • CRM への登録
  • スプレッドシートへの記録
  • Slack や Discord への通知
  • 資料請求データの保存
  • セミナー申込みの受付

つまり、POST リクエストは 副作用のある操作 です。

そのため、次のような実装だけでは不十分です。

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const response = await fetch("/api/contact", {
    method: "POST",
    body: new FormData(form),
  });

  if (response.ok) {
    alert("送信しました。");
  } else {
    alert("送信に失敗しました。");
  }
});

このコードでも送信はできます。

しかし、次の問題が残ります。

  • 送信中にボタンを連打できる
  • 通信が長引くと待ち続ける
  • ネットワークエラーの扱いがない
  • タイムアウトがない
  • 複数フォームで同じ処理が重複する
  • 自動リトライを入れると二重送信のリスクがある

LP フォームでは、fetch() の書き方だけでなく、送信状態をどう管理するかが重要です。

2. 送信中 UI と二重クリック防止を入れる

クライアントサイドで最初に入れるべきなのは、送信中 UI と二重クリック防止です。

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const submitButton = form.querySelector("button[type='submit']");
  const message = form.querySelector("[data-form-message]");

  if (submitButton.disabled) return;

  submitButton.disabled = true;
  submitButton.textContent = "送信中...";
  message.textContent = "";

  try {
    const response = await fetch("/api/contact", {
      method: "POST",
      body: new FormData(form),
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    form.reset();
    message.textContent = "お問い合わせを受け付けました。";
  } catch (error) {
    message.textContent = "送信に失敗しました。時間をおいて再度お試しください。";
  } finally {
    submitButton.disabled = false;
    submitButton.textContent = "送信する";
  }
});

ポイントは次の3つです。

1つ目は、送信中にボタンを disabled にすることです。これにより、通常ユーザーの二重クリックを防げます。

2つ目は、response.ok を確認することです。fetch() は HTTP ステータスが 400500 でも、それだけでは例外になりません。通信自体が成立していれば Promise は解決されます。そのため、response.ok の確認は必要です。

3つ目は、finally でボタン状態を戻すことです。成功しても失敗しても UI が固まらないようにします。

ただし、これはあくまで UI 上の二重クリック防止です。ページを再読み込みしたり、別タブから送信したり、直接 API を呼び出したりするケースまでは防げません。

つまり、クライアントサイドの二重送信防止は、通常ユーザーの誤操作を減らすための UX 対策です。

3. タイムアウトとキャンセルを扱う

フォーム送信では、通信が長引いて「送信中...」のまま止まって見える状態を避けたいです。

そのため、タイムアウトを入れます。

async function postForm(url, formData, timeoutMs = 10000) {
  const controller = new AbortController();

  const timeoutId = setTimeout(() => {
    controller.abort();
  }, timeoutMs);

  try {
    const response = await fetch(url, {
      method: "POST",
      body: formData,
      signal: controller.signal,
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return response;
  } finally {
    clearTimeout(timeoutId);
  }
}

フォーム側では次のように使います。

try {
  await postForm("/api/contact", new FormData(form), 10000);
  message.textContent = "お問い合わせを受け付けました。";
} catch (error) {
  if (error.name === "AbortError") {
    message.textContent = "通信がタイムアウトしました。時間をおいて再度お試しください。";
  } else {
    message.textContent = "送信に失敗しました。";
  }
}

最近のブラウザーを前提にできるなら、AbortSignal.timeout() を使うと短く書けます。

const response = await fetch("/api/contact", {
  method: "POST",
  body: new FormData(form),
  signal: AbortSignal.timeout(10000),
});

ただし、LP は閲覧環境が幅広くなりやすいため、互換性や説明しやすさを重視するなら、AbortController + setTimeout の形でも十分です。

ここで注意したいのは、abort() は「送信取り消し」ではないということです。

AbortController による中断は、ブラウザ側がレスポンス待ちをやめる処理です。サーバー側の処理がすでに始まっていれば、問い合わせは受付済みの可能性があります。

そのため、UI 文言には注意が必要です。

避けたい表現は次です。

送信を取り消しました。

より安全なのは、次のような表現です。

通信がタイムアウトしました。送信済みの可能性があります。
時間をおいて再度お試しください。

クライアントサイドのキャンセルは、サーバー側の処理停止を保証するものではありません。

4. 自動リトライは原則入れない

LP フォームの POST では、自動リトライは慎重に扱うべきです。

GET リクエストなら、自動リトライしても問題になりにくい場面があります。しかし、問い合わせフォームの POST は副作用を持ちます。

たとえば、次のようなことが起こりえます。

ユーザーが送信する
↓
サーバー側では問い合わせを受付済み
↓
しかしレスポンスだけ失敗する
↓
ブラウザ側は失敗と判断する
↓
自動リトライする
↓
二重受付になる

そのため、静的 LP のフォーム送信では、基本的に POST の自動リトライは入れない ほうが安全です。

失敗時は、ユーザーに手動で再送信してもらう設計のほうが説明しやすいです。

送信に失敗しました。時間をおいて再度お試しください。

どうしてもリトライを扱いたい場合は、Idempotency-Key のような一意なキーを送る設計があります。

await fetch("/api/contact", {
  method: "POST",
  body: formData,
  headers: {
    "Idempotency-Key": crypto.randomUUID(),
  },
});

ただし、これはクライアントだけで効く魔法ではありません。

サーバー側が同じキーを記録し、同じキーのリクエストを二重処理しないことで初めて意味を持ちます。

つまり、クライアントサイドだけでできるのは、重複防止のための情報を送るところまでです。実際に重複処理を防ぐのはサーバー側の責務です。

5. 複数フォームなら薄い fetch ラッパーを作る

1つの LP でも、複数フォームを置くことがあります。

たとえば、次のようなケースです。

  • 問い合わせフォーム
  • 資料請求フォーム
  • セミナー申込みフォーム
  • メールマガジン登録フォーム
  • 無料相談フォーム

この場合、各フォームに fetch() を直書きすると、処理がズレやすくなります。

たとえば、あるフォームにはタイムアウトがあるのに、別のフォームにはない。あるフォームでは response.ok を見ているのに、別のフォームでは見ていない。こうした差分が生まれます。

そのため、複数フォームがあるなら、薄い fetch ラッパーを作る価値があります。

async function postForm(url, formData, options = {}) {
  const {
    timeoutMs = 10000,
    headers = {},
  } = options;

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      method: "POST",
      body: formData,
      signal: controller.signal,
      headers: {
        Accept: "application/json",
        ...headers,
      },
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return response;
  } finally {
    clearTimeout(timeoutId);
  }
}

フォームごとの処理は、設定だけ変えます。

function bindAjaxForm(selector, options) {
  const form = document.querySelector(selector);
  const submitButton = form.querySelector("button[type='submit']");
  const message = form.querySelector("[data-form-message]");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();

    if (submitButton.disabled) return;

    submitButton.disabled = true;
    submitButton.textContent = "送信中...";
    message.textContent = "";

    try {
      await postForm(options.url, new FormData(form), {
        timeoutMs: options.timeoutMs ?? 10000,
      });

      form.reset();
      message.textContent = options.successMessage ?? "送信しました。";
    } catch (error) {
      message.textContent =
        error.name === "AbortError"
          ? "通信がタイムアウトしました。時間をおいて再度お試しください。"
          : "送信に失敗しました。";
    } finally {
      submitButton.disabled = false;
      submitButton.textContent = "送信する";
    }
  });
}

使う側はシンプルになります。

bindAjaxForm("#contact-form", {
  url: "/api/contact",
  successMessage: "お問い合わせを受け付けました。",
});

bindAjaxForm("#download-form", {
  url: "/api/download-request",
  successMessage: "資料請求を受け付けました。",
});

このとき、fetch ラッパーに何でも詰め込みすぎないことも大切です。

目安は次のように考えるとよいです。

処理 入れる場所
POST 指定 fetch ラッパー
response.ok 判定 fetch ラッパー
タイムアウト fetch ラッパー
共通ヘッダー fetch ラッパー
送信中ボタン制御 フォーム制御関数
メッセージ表示 フォーム制御関数
DOM 操作 フォーム制御関数
自動リトライ 初期状態では入れない

ky などのライブラリを使う場合も、考え方は同じです。

フォームが1つだけなら、fetch() 直書きでも十分です。
複数フォームがあるなら、自作の小さな postForm() を作る。
さらに複数ページ、複数 API、共通 hooks、JSON 処理まで増えてきたら、ky のような fetch ラッパーを検討する。

この順番で十分です。

クライアントサイドでできること・できないこと

最後に、クライアントサイドで対応できる範囲を整理します。

課題 クライアントサイドでできること 限界
二重クリック ボタンを disabled にする 直接 API 呼び出しは防げない
タイムアウト AbortController で中断する サーバー処理の停止は保証できない
キャンセル レスポンス待ちをやめる 送信取り消しではない
バリデーション HTML 制約検証、事前チェック 信頼できる検証はサーバー側が必要
重複投稿 UI 制御、キー送信 確実な重複防止はサーバー側が必要
スパム対策 トークン取得など 判定と拒否はサーバー側が必要
リトライ 手動再送信 UI 自動リトライは二重投稿リスクがある

クライアントサイドでできるのは、ユーザー体験を整え、誤操作を減らし、サーバー側に必要な情報を渡すことです。

受付の正しさ、重複防止、スパム対策、バリデーションの本体はサーバー側にあります。

まとめ

静的 LP の Ajax フォームでは、fetch() で POST できるだけでは不十分です。

最低限、次の点を考える必要があります。

  • 送信中 UI を表示する
  • 送信ボタンの二重クリックを防ぐ
  • response.ok を確認する
  • タイムアウトを入れる
  • キャンセルを「送信取り消し」と誤解しない
  • POST の自動リトライは原則入れない
  • 複数フォームがあるなら薄い fetch ラッパーを作る

静的 LP のフォーム送信は、単なる通信処理ではありません。

フォームの POST は、問い合わせや申込みを受け付ける入口です。

クライアントサイドの役割は、受付処理そのものを完全に安全にすることではなく、ユーザーが迷わず送信でき、誤操作を減らし、サーバー側の受付処理へ安全に渡すことです。

この前提で設計すると、fetch() 直書きで足りる場面と、薄いラッパーを作るべき場面を判断しやすくなります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?