静的なランディングページに問い合わせフォームや資料請求フォームを置く場合、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 ステータスが 400 や 500 でも、それだけでは例外になりません。通信自体が成立していれば 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() 直書きで足りる場面と、薄いラッパーを作るべき場面を判断しやすくなります。