Enviar formulário com fetch sem recarregar e sem esconder o erro
Envie o formulário com fetch e FormData, leia o código HTTP de verdade, mostre cada erro ao lado do campo certo e trate rede caída e sessão expirada sem fingir sucesso.
A pessoa preenche o pedido de orçamento, clica em "Enviar" e aparece um "Enviado com sucesso" verde no canto. No painel, nada chegou. O script mostrava sucesso sempre que a requisição terminava, e o servidor tinha respondido com um erro de validação dentro de um 200. A resposta curta: o servidor devolve o código HTTP certo e o script decide pelo código, não pelo fato de a resposta ter chegado. 201 quando gravou, 422 com a lista de erros por campo, 401 quando a sessão caiu. Rede fora do ar é outro caso, que só aparece no catch.
O exemplo deste guia é o formulário de orçamento de uma empresa de reformas, com nome, e-mail e descrição, enviado com fetch para um endpoint em PHP.
Por que o envio sem recarregar engana
No envio tradicional, o navegador troca a página inteira. Se deu erro, a página nova mostra o erro; se deu certo, mostra a confirmação. Não tem como o resultado ficar escondido. No envio por JavaScript, a página fica parada e tudo o que a pessoa vê é o que o seu script decidiu mostrar.
O fetch tem um detalhe que pega muita gente: ele só rejeita a promessa quando a rede falha. Uma resposta 422, 500 ou 404 chega normalmente, com response.ok igual a false. Se o código só trata o catch, todo erro do servidor vira sucesso na tela.
Há um segundo detalhe. Por padrão, o fetch segue redirecionamentos. Se a sessão expirou e o PHP redireciona para a página de login, o script recebe a página de login com status 200, e nada nela diz "erro". A propriedade response.redirected e o tipo de conteúdo da resposta são o que denuncia isso.
Como montar o envio que mostra a verdade
- Escreva o formulário como se não houvesse JavaScript:
action,method="post"e botão de enviar. Se o script falhar, ele ainda funciona. - Reserve um parágrafo de erro para cada campo e uma região de status geral com
role="status", que o leitor de tela anuncia. - No evento
submit, cancele o envio normal, desabilite o botão e mande os dados comFormData. - Peça JSON com o cabeçalho
Accepte, no servidor, responda com o código certo e um corpo JSON. - No script, trate quatro saídas: rede caiu, sessão expirou, erro de validação e sucesso. Qualquer outra coisa é erro inesperado.
- Em erro de validação, mostre cada mensagem ao lado do campo, marque o campo com
aria-invalide leve o foco ao primeiro erro. - Reabilite o botão em toda saída que não seja sucesso, sem apagar o que a pessoa escreveu.
O formulário
<form id="orcamento" action="/orcamento" method="post" novalidate>
<label for="nome">Nome</label>
<input id="nome" name="nome" autocomplete="name" aria-describedby="nome-erro">
<p id="nome-erro" hidden></p>
<label for="email">E-mail</label>
<input id="email" name="email" type="email" autocomplete="email" aria-describedby="email-erro">
<p id="email-erro" hidden></p>
<label for="descricao">O que você precisa reformar</label>
<textarea id="descricao" name="descricao" aria-describedby="descricao-erro"></textarea>
<p id="descricao-erro" hidden></p>
<button type="submit">Pedir orçamento</button>
<p id="orcamento-status" role="status"></p>
</form>
O script
const form = document.getElementById('orcamento');
const botao = form.querySelector('button[type="submit"]');
const status = document.getElementById('orcamento-status');
function limparErros() {
for (const campo of form.querySelectorAll('[aria-invalid="true"]')) {
campo.removeAttribute('aria-invalid');
const erro = document.getElementById(campo.id + '-erro');
erro.hidden = true;
erro.textContent = '';
}
}
function mostrarErros(erros) {
let primeiro = null;
for (const [nome, mensagem] of Object.entries(erros)) {
const campo = form.elements[nome];
if (!campo) continue;
campo.setAttribute('aria-invalid', 'true');
const erro = document.getElementById(campo.id + '-erro');
erro.textContent = mensagem;
erro.hidden = false;
primeiro = primeiro || campo;
}
if (primeiro) primeiro.focus();
}
form.addEventListener('submit', async function (evento) {
evento.preventDefault();
limparErros();
botao.disabled = true;
status.textContent = 'Enviando...';
let resposta;
try {
resposta = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'Accept': 'application/json' }
});
} catch (falha) {
status.textContent = 'Não foi possível enviar. Confira a conexão e tente de novo.';
botao.disabled = false;
return;
}
const tipo = resposta.headers.get('Content-Type') || '';
const dados = tipo.includes('application/json') ? await resposta.json() : null;
if (resposta.status === 401 || resposta.redirected) {
status.textContent = 'Sua sessão expirou. Entre de novo; o texto continua aqui.';
} else if (!dados) {
status.textContent = 'O servidor respondeu algo inesperado. Tente de novo em instantes.';
} else if (resposta.ok) {
status.textContent = dados.mensagem;
form.reset();
} else if (resposta.status === 422) {
status.textContent = 'Confira os campos marcados.';
mostrarErros(dados.erros || {});
} else {
status.textContent = 'Não conseguimos registrar o pedido. Tente de novo em instantes.';
}
if (!resposta.ok || !dados) botao.disabled = false;
});
O atributo novalidate desliga os balões de validação nativos para que todos os erros apareçam no mesmo lugar e com o mesmo texto; se preferir manter os nativos, tire-o, mas o servidor continua sendo quem decide. Repare que a ordem dos testes começa por sessão e por resposta que não é JSON, antes de olhar resposta.ok. É isso que impede a página de login, que chega com 200, de virar "sucesso".
O endpoint em PHP
<?php
header('Content-Type: application/json; charset=utf-8');
$nome = trim($_POST['nome'] ?? '');
$email = trim($_POST['email'] ?? '');
$descricao = trim($_POST['descricao'] ?? '');
$erros = [];
if ($nome === '') {
$erros['nome'] = 'Informe seu nome.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$erros['email'] = 'Informe um e-mail válido, como nome@exemplo.com.br.';
}
if (mb_strlen($descricao, 'UTF-8') < 20) {
$erros['descricao'] = 'Conte um pouco mais, com pelo menos 20 caracteres.';
}
if ($erros) {
http_response_code(422);
echo json_encode(['erros' => $erros], JSON_UNESCAPED_UNICODE);
exit;
}
// gravar o pedido com consulta parametrizada
http_response_code(201);
echo json_encode(['mensagem' => 'Pedido recebido. Respondemos em até um dia útil.'], JSON_UNESCAPED_UNICODE);
As chaves de $erros são os mesmos valores do atributo name dos campos, por isso o script consegue achar cada um com form.elements[nome]. A gravação em si segue as regras de sempre; se for Node em vez de PHP, o guia de Node.js e MySQL com parâmetro mostra a consulta.
Duplo clique e reenvio
Desabilitar o botão evita o segundo clique impaciente, mas não evita tudo: a rede pode cair depois de o servidor gravar e antes de a resposta chegar. A pessoa vê "não foi possível enviar", clica de novo, e o orçamento entra duas vezes. Para orçamento, isso é um incômodo. Para pagamento, é cobrança dupla, e aí a solução é a chave de idempotência descrita em idempotência no pagamento. O detalhe visual do botão enquanto espera está em botão que espera.
E nem todo formulário deve ficar sem recarregar. Se a próxima etapa é outra página, como o resumo do pedido, o envio tradicional com redirecionamento dá um endereço próprio e um botão voltar que funciona sem esforço.
Erros comuns
- Tratar só o
catch. Erro 422 e 500 chegam como resposta normal, e a tela mostra sucesso para um pedido que não foi gravado. - Erro de validação dentro de um 200. O script precisa ler o texto para adivinhar, e qualquer mudança na mensagem quebra a lógica.
- Ignorar o redirecionamento para o login. A sessão expirada vira "enviado com sucesso" e a pessoa só descobre quando ninguém responde.
- Mostrar todos os erros num alerta único. A pessoa fecha a caixa e já não lembra qual campo estava errado.
- Apagar o formulário em caso de erro. Quem escreveu três parágrafos de descrição desiste.
- Botão habilitado durante o envio. Dois cliques geram dois registros.
Checklist
- O formulário funciona com o JavaScript desligado.
- O servidor responde 201, 422 e 401 com corpo JSON, nunca erro dentro de 200.
- O script testa sessão, tipo de conteúdo, sucesso e validação nessa ordem.
- Cada erro aparece ao lado do seu campo, com
aria-invalid. - O foco vai para o primeiro campo com erro.
- A região de status tem
role="status". - O botão fica desabilitado durante o envio e volta em caso de falha.
- Com a rede desligada, a tela diz que não enviou.
Abra o seu formulário, desligue a rede nas ferramentas do navegador e clique em enviar. A tela disse que salvou? E se você deixar a sessão expirar antes de enviar, o que a pessoa vê?
Referência: http://rafaelcouto.com.br/envio-de-formulario-sem-refresh-com-jquery-php/