Envio de formulários HTML

Envio de formulários HTML7 min de leitura

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.

Preet SutharFundador, Ikiform

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.

O que um script faz ao enviar um formulário com fetch em vez de deixar o navegador navegareventosubmitdepois daschecagens donavegadorpreventDefault()sem navegaçãofetchFormDataAccept:application/json201troca por umafrase deagradecimentomensagem nocampofoco vai paralá, botão voltaa funcionar422 ou erro de redeO que um script faz ao enviar um formulário com fetch em vez de deixar o navegador navegarevento submitdepois das checagensdo navegadorpreventDefault()sem navegaçãofetch FormDataAccept:application/json201troca por uma frase deagradecimentomensagemno campofoco vaipara lá,botão voltaa funcionar422 ou erro de rede
  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 istoAgora seu script precisa
Mostrava um estado de carregamento na abaMostrar um, e anunciá-lo a leitores de tela
Tornava cliques duplos quase inofensivosIgnorar um segundo envio enquanto um está em andamento
Mostrava a página de erroMapear error.fields para cada input
Mostrava uma página de erro de redeCapturar requisições que falham e timeouts
Movia o foco para a nova páginaMover 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.

Prévia

Roda o validador do Ikiform no seu navegador. Nada é enviado.

Esta página ajudou?
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
<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
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 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 e passe para submit(). A documentação de spam 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
<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 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

ProblemaCausa e solução
Um campo está faltando no envioEle 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 vazioO código definiu Content-Type: multipart/form-data à mão, sem boundary. Apague o header
A página recarrega mesmo assimO 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 erroA 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_requiredA 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 spamO 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

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.