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.
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 navegador roda a checagem de restrições primeiro. O evento submit só dispara quando o formulário é válido.
- Seu listener chama preventDefault, então a aba não navega.
- Ele envia os dados do formulário com fetch e pede JSON.
- No 201, ele troca o formulário por uma mensagem de status e move o foco para ela.
- Num 422 ou numa falha de rede, ele mostra a mensagem ao lado do campo, foca nele e reabilita o envio.
| 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.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
<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
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 onamee ovaluedo botão clicado, se ele tiver. Monte-o antes de mudar qualquer coisa no formulário.- O botão recebe
aria-disabled, nãodisabled. A flagsendingfaz 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 corpoFormData, o navegador definemultipart/form-datacom o boundary certo. Accept: application/jsonfaz o Ikiform responder em JSON. Uma chamadafetchmandaSec-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 headerIdempotency-Keyfaz 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á:
<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.
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
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Envie Sim sem comentário. O formulário vira Obrigado, isso ajuda. e o painel Network mostra
201. - Cole mais de 2.000 caracteres no comentário, remova o
maxlengthnas ferramentas de desenvolvedor e envie. A mensagem aparece embaixo do comentário e o foco vai para lá. - Dê um clique duplo em Enviar. O painel mostra um envio, não dois.
- Desligue o JavaScript e envie. Você chega à página de
_redirect. - 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
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.
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.
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.
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.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.