# Enviar um formulário HTML sem recarregar a página

> Envie um formulário com fetch e FormData para o visitante continuar na página, com erros de campo, estado de carregamento, novas tentativas e um plano B quando scripts falham.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/html-form-submission/without-page-reload

Escute o evento `submit` do formulário, chame `preventDefault()` e envie `new FormData(form)` com `fetch` e um header `Accept: application/json`. O Ikiform então responde com JSON em vez de um redirecionamento: `201` em caso de sucesso, ou erros de campo que você mostra ao lado de cada input. Mantenha um `action` e um `method` reais no formulário para que ele ainda envie do jeito tradicional se o seu script nunca rodar.

O exemplo aqui é uma caixa **Esta página ajudou?** no fim de uma página de documentação. É o caso clássico para ficar na página: o leitor está no meio de uma tarefa, e mandá-lo para uma página de agradecimento para registrar um único clique seria absurdo. O mesmo padrão serve para qualquer formulário.

## O que muda quando você assume o envio

Um envio simples de formulário te dá muita coisa de graça: o navegador mostra um indicador de carregamento, faz com que cliques duplos importem pouco, cuida do botão voltar e mostra a página que voltar. No momento em que você chama `preventDefault()`, tudo isso vira trabalho seu. A chamada fetch em si tem três linhas; o resto desta página é o trabalho que o navegador fazia.

Diagrama: O que um script faz ao enviar um formulário com fetch em vez de deixar o navegador navegar

1. O navegador roda a checagem de restrições primeiro. O evento submit só dispara quando o formulário é válido.
2. Seu listener chama preventDefault, então a aba não navega.
3. Ele envia os dados do formulário com fetch e pede JSON.
4. No 201, ele troca o formulário por uma mensagem de status e move o foco para ela.
5. Num 422 ou numa falha de rede, ele mostra a mensagem ao lado do campo, foca nele e reabilita o envio.

A validação do navegador ainda roda antes do seu código. Tudo depois do preventDefault fica por sua conta.

| O navegador fazia isto | Agora seu script precisa |
| --- | --- |
| Mostrava um estado de carregamento na aba | Mostrar um, e anunciá-lo a leitores de tela |
| Tornava cliques duplos quase inofensivos | Ignorar um segundo envio enquanto um está em andamento |
| Mostrava a página de erro | Mapear `error.fields` para cada input |
| Mostrava uma página de erro de rede | Capturar requisições que falham e timeouts |
| Movia o foco para a nova página | Mover o foco para o resultado ou para o primeiro campo inválido |

## O formulário

Três campos: um sim ou não obrigatório, um comentário opcional e o endereço da página preenchido pelo script. Experimente a definição abaixo; é o que o Ikiform aplica no servidor, então um comentário com mais de 2.000 caracteres recebe um `422` aqui também.

Formulário: Was this page helpful

- **Esta página ajudou?** (`helpful`, radio, obrigatório)
- **O que faltou?** (`comment`, textarea)
- **Página** (`page`, url)

Cole isto na aba **Fields** (campos) do formulário. Com campos definidos, o Ikiform também recusa qualquer campo que não conhece.

`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>
```

O formulário está completo sem o script. Com JavaScript desligado ou bloqueado, ele envia do jeito tradicional, o Ikiform responde `303` e o leitor chega à página de `_redirect`. O script só melhora isso.

## O script

`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)` lê todo controle com nome, mais o `name` e o `value` do botão clicado, se ele tiver. Monte-o antes de mudar qualquer coisa no formulário.
- O botão recebe `aria-disabled`, não `disabled`. A flag `sending` faz o bloqueio de verdade, e o botão continua focável para que usuários de leitor de tela não percam o lugar.
- Sem header `Content-Type`. Com um corpo `FormData`, o navegador define `multipart/form-data` com o boundary certo.
- `Accept: application/json` faz o Ikiform responder em JSON. Uma chamada `fetch` manda `Sec-Fetch-Mode: cors`, que escolheria JSON de qualquer forma, mas o header deixa a intenção clara.
- Uma chave de idempotência por carregamento de página, enviada como o campo `_idempotency`. Se o leitor envia, a rede perde a resposta e ele envia de novo, o Ikiform repete a primeira resposta em vez de armazenar um segundo envio. Um envio recusado nunca consome a chave, então um reenvio corrigido passa. O header `Idempotency-Key` faz o mesmo, mas um header personalizado faz o navegador mandar uma requisição de preflight antes. [JavaScript e fetch](https://ikiform.com/pt-br/docs/frameworks.md#idempotency) tem as regras exatas.
- `AbortSignal.timeout(15000)` transforma uma requisição travada no ramo catch em vez de um **Sending...** (enviando) sem fim.
- A linha de status tem `role="status"`, então o texto dela é lido sem mover o foco. No sucesso, o foco vai para ela porque os campos ao redor sumiram.

## Quando o formulário tem verificação humana

Se o **Spam check** (verificação de spam) do formulário está definido como **Ikiform**, um envio por `fetch` sem token do Turnstile recebe `403 challenge_required`. O script acima trata isso chamando `form.submit()`. Esse método pula o listener de submit e envia do jeito tradicional, e para um envio simples o Ikiform mostra a própria página curta de verificação e depois segue para a sua página de `_redirect`. O leitor só sai da página quando a verificação está ligada.

Para continuar na página mesmo assim, obtenha antes um token com `challenge(formId)` do [SDK](https://ikiform.com/pt-br/docs/sdk.md) e passe para `submit()`. A [documentação de spam](https://ikiform.com/pt-br/docs/spam.md#human-check) mostra o fluxo.

## A alternativa sem JavaScript

Antes do `fetch`, o truque era mirar num iframe oculto, para que a resposta carregue nele e a página fique onde está:

`Truque antigo`:

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

Ainda funciona, mas o iframe guarda uma página em ikiform.com, e o navegador não deixa seu script ler um frame de outra origem. Você não consegue distinguir sucesso de erro de validação, então o leitor não recebe nenhum retorno em nenhum dos casos. Use só onde um script realmente não pode rodar.

> Um framework de servidor pode dar envio sem recarregar sem código fetch no cliente: server actions do Next.js, o `use:enhance` do SvelteKit e o `<Form>` do React Router enviam do jeito tradicional antes da hidratação e sem recarregar depois. A [visão geral](https://ikiform.com/pt-br/guides/html-form-submission.md#implementation) mostra cada um.

## Confira se funciona

1. Em **Allowed domains** do formulário, adicione o domínio do seu site, mais `localhost` enquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar.
2. Envie **Sim** sem comentário. O formulário vira **Obrigado, isso ajuda.** e o painel Network mostra `201`.
3. Cole mais de 2.000 caracteres no comentário, remova o `maxlength` nas ferramentas de desenvolvedor e envie. A mensagem aparece embaixo do comentário e o foco vai para lá.
4. Dê um clique duplo em **Enviar**. O painel mostra um envio, não dois.
5. Desligue o JavaScript e envie. Você chega à página de `_redirect`.
6. Defina o throttling como **Offline** e envie. A linha de status diz que não conseguiu alcançar o servidor, e **Enviar** volta a funcionar.

## Pegadinhas

| Problema | Causa e solução |
| --- | --- |
| Um campo está faltando no envio | Ele estava `disabled` quando o `FormData` foi montado. Controles desabilitados nunca são enviados. Monte os dados primeiro, ou trave os inputs com `readonly` |
| `400 invalid_body` ou um envio vazio | O código definiu `Content-Type: multipart/form-data` à mão, sem boundary. Apague o header |
| A página recarrega mesmo assim | O listener lançou um erro antes do `preventDefault()`, ou o clique veio antes de o script carregar. Chame `preventDefault()` primeiro; um clique precoce envia do jeito tradicional, que continua funcionando |
| `response.json()` lança erro | A requisição foi para um lugar que respondeu HTML, geralmente um `action` relativo. Leia `form.action` no console; precisa ser a URL do seu Ikiform |
| `403 challenge_required` | A verificação humana do formulário está ligada. Recorra a `form.submit()` como acima, ou obtenha um token com `challenge()` |
| `403 origin_not_allowed`, ou todo envio cai no spam | O domínio da página não está em **Allowed domains** do formulário. Adicione-o, mais `localhost` para testes locais. Subdomínios de um domínio listado são permitidos. |

## Perguntas

### Como impedir que um formulário recarregue a página ao enviar?

Adicione um listener de submit e chame event.preventDefault() na primeira linha, depois envie os dados você mesmo com fetch. Escute o evento submit do formulário, não o click do botão, para cobrir também a tecla Enter.

### Preciso definir Content-Type ao enviar FormData com fetch?

Não, e nem deveria. O navegador define multipart/form-data com a string de boundary. Um header que você escreve não tem boundary, e o servidor não consegue interpretar o corpo.

### O navegador ainda valida o formulário se eu enviar com JavaScript?

Sim, desde que você escute o evento submit, que só dispara depois que a checagem de restrições do navegador passa. Chamar form.submit() pelo código pula tanto a checagem quanto o evento; form.requestSubmit() roda os dois.

### Como evitar envios duplicados?

Ignore envios enquanto uma requisição está em andamento, e mande uma chave de idempotência por carregamento de página, como o campo _idempotency ou o header Idempotency-Key. Se uma nova tentativa ainda chegar ao Ikiform, ele repete a primeira resposta em vez de armazenar o envio de novo.

## Mais guias

- [Envio de formulários HTML](https://ikiform.com/pt-br/guides/html-form-submission.md): O que o navegador faz quando um formulário é enviado: quais campos manda, como os codifica, o que o servidor deve responder e como o Ikiform responde.
- [Validação de formulários](https://ikiform.com/pt-br/guides/html-form-submission/validation.md): Valide um formulário com required, type, pattern e min, estilize erros com :user-invalid e aplique as mesmas regras no servidor com definições de campos.
- [Redirecionar após o envio](https://ikiform.com/pt-br/guides/html-form-submission/redirect-after-submit.md): Mande visitantes para sua própria página de agradecimento depois do envio: o redirecionamento 303, o campo _redirect, quais domínios são permitidos e o que o refresh faz.
- [O atributo action](https://ikiform.com/pt-br/guides/html-form-submission/action-attribute.md): O que o atributo action de um formulário faz, como valores relativos e vazios são resolvidos, como formaction manda um formulário a dois lugares e por que hosts estáticos respondem 405.
