# ページを再読み込みせずに HTML フォームを送信する

> fetch と FormData でフォームを送り、訪問者をページにとどめる。フィールドのエラー、読み込み状態、リトライ、スクリプト失敗時のフォールバック付き。

公開日 2026-10-25 · https://ikiform.com/ja/guides/html-form-submission/without-page-reload

フォームの `submit` イベントを監視して `preventDefault()` を呼び、`new FormData(form)` を `fetch` と `Accept: application/json` ヘッダーで送ります。すると Ikiform はリダイレクトではなく JSON で応答します。成功なら `201`、そうでなければ各入力欄の横に表示するフィールドのエラーです。スクリプトが動かなくてもプレーンな方法で送信されるよう、フォームには本物の `action` と `method` を残しておきましょう。

ここでの例は、ドキュメントページの下部にある **Was this page helpful?**（このページは役に立ちましたか？）ボックスです。ページにとどまるべき典型例です。読者は作業の途中ですし、1 回のクリックを記録するためにサンクスページへ移動させるのはばかげています。同じパターンはどんなフォームにも使えます。

## 送信を自分で引き受けると何が変わるか

プレーンなフォーム送信では多くのことを無料でやってもらえます。ブラウザーが読み込み表示を出し、ダブルクリックをほぼ無害にし、戻るボタンを処理し、返ってきたページを表示します。`preventDefault()` を呼んだ瞬間、そのすべてが自分の仕事になります。fetch の呼び出し自体は 3 行で、このページの残りはブラウザーがやっていた仕事です。

図: ブラウザーに遷移させず、スクリプトが fetch でフォームを送信するときの処理

1. ブラウザーが最初に制約チェックを実行します。submit イベントはフォームが有効なときだけ発火します。
2. リスナーが preventDefault を呼ぶので、タブは遷移しません。
3. fetch でフォームのデータを送り、JSON を要求します。
4. 201 なら、フォームをステータスメッセージに置き換え、そこにフォーカスを移します。
5. 422 やネットワーク障害なら、フィールドの横にメッセージを表示してフォーカスし、再送信できるようにします。

ブラウザーのバリデーションは今もコードより先に実行されます。preventDefault 以降はすべて自分で処理します。

| ブラウザーがしていたこと | 今はスクリプトがすること |
| --- | --- |
| タブに読み込み状態を表示 | 表示し、スクリーンリーダーにも伝える |
| ダブルクリックをほぼ無害に | 送信中の 2 回目の submit を無視する |
| エラーページを表示 | `error.fields` を各入力欄に割り当てる |
| ネットワークエラーのページを表示 | 失敗したリクエストとタイムアウトを捕捉する |
| 新しいページにフォーカスを移動 | 結果か最初の不正なフィールドにフォーカスを移す |

## フォーム

フィールドは 3 つです。必須の「はい／いいえ」、任意のコメント、スクリプトが入れるページのアドレスです。下の定義を試してみてください。これは Ikiform がサーバーで適用するものなので、2,000 文字を超えるコメントはここでも `422` になります。

フォーム: Was this page helpful

- **このページは役に立ちましたか？** (`helpful`, radio, 必須)
- **何が足りませんでしたか？** (`comment`, textarea)
- **ページ** (`page`, url)

これをフォームの **Fields** タブに貼り付けます。フィールドを定義すると、Ikiform は知らないフィールドも拒否します。

`feedback.html`:

```html
<form id="page-feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>Was this page helpful?</legend>
    <label><input type="radio" name="helpful" value="yes" required /> Yes</label>
    <label><input type="radio" name="helpful" value="no" /> No</label>
  </fieldset>

  <label for="comment">What was missing?</label>
  <textarea id="comment" name="comment" rows="3" maxlength="2000" aria-describedby="comment-error"></textarea>
  <p id="comment-error" hidden></p>

  <input type="hidden" name="page" />
  <input type="hidden" name="_redirect" value="https://docs.example.com/feedback-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="feedback-status" role="status" tabindex="-1"></p>
</form>

<script type="module" src="/page-feedback.js"></script>
```

フォームはスクリプトなしでも完結しています。JavaScript がオフやブロックされていればプレーンな方法で送信され、Ikiform が `303` を返し、読者は `_redirect` のページに着きます。スクリプトはそれを改善するだけです。

## スクリプト

`page-feedback.js`:

```js
const form = document.querySelector("#page-feedback");
const status = form.querySelector("#feedback-status");
const button = form.querySelector("button");
const pageInput = form.elements.namedItem("page");
const idempotencyKey = crypto.randomUUID();
let sending = false;

pageInput.value = location.href;

function showFieldErrors(fields) {
  for (const input of form.querySelectorAll("[aria-invalid]")) input.removeAttribute("aria-invalid");
  for (const hint of form.querySelectorAll("[id$='-error']")) hint.hidden = true;
  let first = null;
  for (const [name, messages] of Object.entries(fields)) {
    const control = form.elements.namedItem(name);
    const hint = form.querySelector(`#${name}-error`);
    if (control instanceof HTMLElement) control.setAttribute("aria-invalid", "true");
    if (hint) {
      hint.textContent = messages[0];
      hint.hidden = false;
    }
    first ??= control instanceof RadioNodeList ? control[0] : control;
  }
  first?.focus();
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (sending) return;
  const body = new FormData(form, event.submitter);
  body.set("_idempotency", idempotencyKey);
  sending = true;
  button.setAttribute("aria-disabled", "true");
  status.textContent = "Sending...";

  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body,
      signal: AbortSignal.timeout(15000),
    });
    const result = await response.json();

    if (result.ok) {
      form.replaceChildren(status);
      status.textContent = "Thanks, that helps.";
      status.focus();
      return;
    }
    if (result.error.code === "challenge_required") {
      form.submit();
      return;
    }
    if (result.error.fields) {
      status.textContent = "";
      showFieldErrors(result.error.fields);
    } else if (response.status === 429 && response.headers.has("Retry-After")) {
      const wait = response.headers.get("Retry-After");
      status.textContent = `Too many tries. Wait ${wait} seconds and send again.`;
    } else {
      status.textContent = result.error.message;
    }
  } catch {
    status.textContent = "Couldn't reach the server. Check your connection and send again.";
  } finally {
    sending = false;
    button.removeAttribute("aria-disabled");
  }
});
```

- `new FormData(form, event.submitter)` は名前付きのすべてのコントロールと、クリックされたボタンに `name` と `value` があればそれも読み取ります。フォームの中身を変える前に作ってください。
- ボタンには `disabled` ではなく `aria-disabled` を付けます。実際にブロックするのは `sending` フラグで、ボタンはフォーカス可能なままなので、スクリーンリーダーのユーザーが位置を見失いません。
- `Content-Type` ヘッダーは付けません。`FormData` のボディなら、ブラウザーが正しい境界付きで `multipart/form-data` を設定します。
- `Accept: application/json` で Ikiform は JSON で応答します。`fetch` 呼び出しは `Sec-Fetch-Mode: cors` を送るのでいずれにせよ JSON が選ばれますが、ヘッダーで意図がはっきりします。
- ページの読み込みごとに冪等性キーを 1 つ作り、`_idempotency` フィールドとして送ります。読者が送信し、ネットワークが応答を落とし、もう一度送信しても、Ikiform は 2 件目の送信データを保存せず、最初の応答を再送します。拒否された送信はキーを使わないので、修正しての再送信は通ります。`Idempotency-Key` ヘッダーでも同じことができますが、カスタムヘッダーだとブラウザーが先にプリフライトリクエストを送ります。正確なルールは [JavaScript と fetch](https://ikiform.com/ja/docs/frameworks.md#idempotency) にあります。
- `AbortSignal.timeout(15000)` により、応答のないリクエストは終わらない **Sending...** ではなく catch の分岐に入ります。
- ステータス行には `role="status"` があるので、フォーカスを動かさずにテキストが読み上げられます。成功時は周りのフィールドがなくなるので、フォーカスをそこに移します。

## フォームに人間確認があるとき

フォームの **Spam check** が **Ikiform** に設定されていると、Turnstile のトークンなしの `fetch` 送信は `403 challenge_required` になります。上のスクリプトは `form.submit()` を呼んでこれに対処します。このメソッドは submit リスナーを飛ばしてプレーンな方法で送信し、プレーンな送信に対して Ikiform は自前の短い確認ページを表示してから `_redirect` のページに進みます。読者がページを離れるのは、チェックがオンのときだけです。

その場合でもページにとどめるには、先に [SDK](https://ikiform.com/ja/docs/sdk.md) の `challenge(formId)` でトークンを取得し、`submit()` に渡します。流れは[スパム対策のドキュメント](https://ikiform.com/ja/docs/spam.md#human-check)にあります。

## JavaScript を使わない代替手段

`fetch` が登場する前は、非表示の iframe をターゲットにして応答をそこに読み込み、ページをそのままにするのが定番の手でした。

`昔の手法`:

```html
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
  ...
</form>
```

今でも動きますが、iframe が保持するのは ikiform.com のページで、ブラウザーはスクリプトがクロスオリジンのフレームを読むことを許しません。成功とバリデーションエラーを区別できないので、読者にはどちらの場合もフィードバックがありません。スクリプトが本当に動かせない場合にだけ使ってください。

> サーバーフレームワークなら、クライアントの fetch コードなしで再読み込みをなくせます。Next.js のサーバーアクション、SvelteKit の `use:enhance`、React Router の `<Form>` は、ハイドレーション前はプレーンな方法で送信し、その後は再読み込みなしで送信します。それぞれの例は[概要](https://ikiform.com/ja/guides/html-form-submission.md#implementation)にあります。

## 動作を確認する

1. フォームの **Allowed domains** に自分のサイトのドメインを追加し、開発中は `localhost` も追加します。空のままにすると、どこからの送信も受け付けます。
2. コメントなしで **Yes** を送信します。フォームが **Thanks, that helps.** に変わり、Network パネルに `201` が表示されます。
3. コメントに 2,000 文字を超える文章を貼り付け、開発者ツールで `maxlength` を削除して送信します。コメントの下にメッセージが表示され、フォーカスがそこに移ります。
4. **Send** をダブルクリックします。ダッシュボードの送信データは 2 件ではなく 1 件です。
5. JavaScript をオフにして送信します。`_redirect` のページに移動します。
6. スロットリングを **Offline** にして送信します。ステータス行にサーバーに接続できなかったと表示され、**Send** がまた使えるようになります。

## ハマりどころ

| 問題 | 原因と対処 |
| --- | --- |
| 送信データにフィールドが含まれていない | `FormData` を作った時点で `disabled` だったためです。無効化されたコントロールは送信されません。先にデータを作るか、`readonly` で入力欄をロックしてください |
| `400 invalid_body` または空の送信データ | コードで `Content-Type: multipart/form-data` を手書きしていて、boundary がありません。ヘッダーを削除してください |
| それでもページが再読み込みされる | リスナーが `preventDefault()` の前に例外を投げたか、スクリプトの読み込み前にクリックされました。最初に `preventDefault()` を呼んでください。早すぎるクリックは普通の方法で送信され、それでも正しく動きます |
| `response.json()` が例外を投げる | リクエストが HTML を返す場所に送られています。たいていは相対パスの `action` です。コンソールで `form.action` を確認してください。Ikiform の URL になっている必要があります |
| `403 challenge_required` | フォームの人間確認がオンになっています。上のように `form.submit()` にフォールバックするか、`challenge()` でトークンを取得してください |
| `403 origin_not_allowed` が返る、またはすべての送信がスパムになる | ページのドメインがフォームの **Allowed domains** にありません。ドメインと、ローカルテスト用の `localhost` を追加してください。登録したドメインのサブドメインは許可されます。 |

## よくある質問

### 送信時にフォームがページを再読み込みしないようにするには？

submit リスナーを追加して最初の行で event.preventDefault() を呼び、fetch で自分でデータを送ります。Enter キーにも対応できるよう、ボタンの click ではなくフォームの submit イベントを監視してください。

### fetch で FormData を送るとき、Content-Type を設定する必要はありますか？

いいえ。設定すべきではありません。ブラウザーが boundary 文字列付きで multipart/form-data を設定します。自分で書いたヘッダーには boundary がなく、サーバーがボディを解析できません。

### JavaScript で送信しても、ブラウザーはフォームを検証しますか？

はい。submit イベントを監視していれば検証されます。このイベントはブラウザーの制約チェックを通過したあとにだけ発生します。コードから form.submit() を呼ぶとチェックもイベントも飛ばされますが、form.requestSubmit() なら両方が実行されます。

### 二重送信を防ぐには？

リクエストの処理中は送信を無視し、ページの読み込みごとに冪等性キーを 1 つ、_idempotency フィールドか Idempotency-Key ヘッダーで送ります。それでもリトライが Ikiform に届いた場合は、送信データを再度保存せず、最初の応答を返します。

## その他のガイド

- [HTML フォームの送信](https://ikiform.com/ja/guides/html-form-submission.md): フォームが送信されたときにブラウザーが行うこと。どのフィールドを送り、どうエンコードし、サーバーは何を返すべきか、そして Ikiform がどう応答するかを解説します。
- [フォームのバリデーション](https://ikiform.com/ja/guides/html-form-submission/validation.md): required、type、pattern、min でフォームを検証し、:user-invalid でエラーをスタイリングし、同じルールをフィールド定義でサーバー側にも適用します。
- [送信後のリダイレクト](https://ikiform.com/ja/guides/html-form-submission/redirect-after-submit.md): フォーム送信後に訪問者を自分のサンクスページへ送る方法。303 リダイレクト、_redirect フィールド、許可されるドメイン、再読み込みの挙動を解説します。
- [action 属性](https://ikiform.com/ja/guides/html-form-submission/action-attribute.md): フォームの action 属性の役割、相対値や空の値の解決のされ方、formaction で 1 つのフォームを 2 か所に送る方法、静的ホストが 405 を返す理由を解説します。
