Programação Por Equipe DevBras

Capa do artigo: Enviar formulário com fetch sem recarregar e sem esconder o erro

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

  1. 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.
  2. 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.
  3. No evento submit, cancele o envio normal, desabilite o botão e mande os dados com FormData.
  4. Peça JSON com o cabeçalho Accept e, no servidor, responda com o código certo e um corpo JSON.
  5. No script, trate quatro saídas: rede caiu, sessão expirou, erro de validação e sucesso. Qualquer outra coisa é erro inesperado.
  6. Em erro de validação, mostre cada mensagem ao lado do campo, marque o campo com aria-invalid e leve o foco ao primeiro erro.
  7. 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ê?

Avalie este artigo

Referência: http://rafaelcouto.com.br/envio-de-formulario-sem-refresh-com-jquery-php/