Programação Por Equipe DevBras

Capa do artigo: Clique duplo no botão de enviar: como não gerar dois pedidos

Clique duplo no botão de enviar: como não gerar dois pedidos

Trave o envio no submit, mostre que está enviando, religue o botão no erro sem apagar o formulário e use uma chave de idempotência para o clique que escapar.

Sexta, 18h40, 4G fraco na fila do mercado. A cliente toca em "Finalizar pedido", nada muda na tela por três segundos, e ela toca de novo. No painel aparecem os pedidos 10231 e 10232, com o mesmo carrinho e duas cobranças no cartão. Ninguém errou de propósito: a tela parecia travada e o botão continuava ali, convidando.

A resposta curta: no primeiro envio, marque o formulário como ocupado, desligue o botão e troque o rótulo para "Enviando pedido…". Se a resposta falhar ou demorar demais, religue o botão e mantenha tudo o que foi digitado. E, como o navegador não é garantia de nada, mande junto uma chave de idempotência para o servidor recusar o segundo pedido que escapar.

Por que um clique vira dois pedidos

O navegador não sabe que o seu formulário cria uma cobrança. Para ele, cada ativação do botão de envio é um envio novo, e cada evento submit roda o seu código outra vez. Com fetch, nada na página muda sozinho: a requisição está no ar, mas a tela é exatamente a mesma de antes do clique.

Há três caminhos para o segundo envio. O toque repetido de quem acha que o primeiro não pegou. O duplo clique de quem aprendeu a abrir pastas assim. E o Enter num campo de texto, que também envia o formulário. Travar só o clique do mouse deixa os outros caminhos abertos, por isso a trava fica no evento submit do formulário, não no click do botão.

O outro lado do problema é o caso de erro. Muita implementação desliga o botão e só religa no sucesso. Quando a API devolve 500 ou a conexão cai, o botão fica cinza para sempre, e a única saída da pessoa é recarregar a página e perder o que digitou.

Como travar o envio do jeito certo

  1. Escute o submit do formulário e use uma variável de controle (enviando). Se ela já estiver ligada, ignore o evento.
  2. Desligue o botão e troque o texto por um que diga o que está acontecendo. "Enviando pedido…" informa; um ícone girando sozinho não diz o quê.
  3. Marque o formulário com aria-busy="true" e tenha uma região de status (role="status") para anunciar o erro a quem usa leitor de tela.
  4. Ponha um limite de tempo na requisição. Sem limite, "esperando" pode durar o tempo que a bateria do celular aguentar.
  5. No erro, religue o botão, volte o rótulo original e diga o que fazer em seguida. Não limpe os campos.
  6. No sucesso, leve a pessoa para a página do pedido. Assim o botão nem precisa voltar.

O código do checkout

O HTML é um formulário comum, com um parágrafo de status vazio logo abaixo do botão:

<form action="/api/pedidos" method="post" data-checkout>
  <label>CEP <input name="cep" autocomplete="postal-code" required></label>
  <button type="submit">Finalizar pedido</button>
  <p data-status role="status"></p>
</form>

O JavaScript cuida da trava, do rótulo, do tempo limite e da volta no erro:

const form = document.querySelector('[data-checkout]');
const botao = form.querySelector('button[type="submit"]');
const status = form.querySelector('[data-status]');
const rotuloOriginal = botao.textContent;
const chave = crypto.randomUUID();
let enviando = false;

form.addEventListener('submit', async (evento) => {
  evento.preventDefault();
  if (enviando) return;
  enviando = true;
  botao.disabled = true;
  botao.textContent = 'Enviando pedido…';
  form.setAttribute('aria-busy', 'true');
  status.textContent = '';

  try {
    const resposta = await fetch(form.action, {
      method: 'POST',
      body: new FormData(form),
      headers: { 'Idempotency-Key': chave },
      signal: AbortSignal.timeout(15000)
    });
    if (!resposta.ok) throw new Error('HTTP ' + resposta.status);
    const pedido = await resposta.json();
    window.location.assign('/pedidos/' + pedido.id);
  } catch (erro) {
    status.textContent = erro.name === 'TimeoutError'
      ? 'A resposta demorou demais. Confira em Meus pedidos antes de tentar de novo.'
      : 'Não deu para enviar. Seus dados continuam aqui, tente de novo.';
    enviando = false;
    botao.disabled = false;
    botao.textContent = rotuloOriginal;
    form.removeAttribute('aria-busy');
  }
});

Repare que a chave é gerada uma vez, quando a página carrega, e não a cada tentativa. Se a primeira requisição chegou ao servidor mas a resposta se perdeu no caminho, a segunda tentativa leva a mesma chave, e o servidor reconhece que aquele pedido já existe.

A mensagem de tempo esgotado é diferente da de erro por um motivo: tempo esgotado não quer dizer que falhou. O pedido pode ter sido criado. Pedir para a pessoa conferir antes de tentar de novo evita a duplicata que o front não consegue enxergar. O post sobre timeout antes do infinito ajuda a escolher esse número com base no tempo real da sua API.

A trava que vale de verdade fica no servidor

O JavaScript pode não ter carregado, a pessoa pode ter duas abas abertas, uma extensão ou um proxy pode reenviar a requisição. O botão desligado é cortesia com quem usa; quem impede a duplicata é o banco. Com a chave no cabeçalho, basta uma coluna e um índice único:

ALTER TABLE pedidos ADD COLUMN chave_idempotencia CHAR(36) NULL;
CREATE UNIQUE INDEX ux_pedidos_chave ON pedidos (chave_idempotencia);

Ao receber o pedido, tente inserir com a chave. Se o banco recusar por chave duplicada, busque o pedido que já existe e devolva o mesmo id, com a mesma resposta de sucesso. Para o cliente, a segunda tentativa parece igual à primeira, que é exatamente o que você quer. O passo a passo completo, incluindo o que fazer quando há um gateway de pagamento no meio, está em idempotência no pagamento.

Detalhes que mudam a experiência

Indicador no lugar, não cortina na página

Se só o formulário trabalha, só ele mostra espera. Cobrir a tela inteira com um fundo escuro impede a pessoa de conferir o carrinho ou copiar o endereço enquanto aguarda, e assusta mais do que informa. O raciocínio completo está em espera curta: um indicador no componente.

Botão desligado e o foco do teclado

Um botão com disabled deixa de ser focável, e quem navega por teclado pode perder a posição na página no meio do envio. Se isso atrapalhar no seu caso, use aria-disabled="true" no lugar e deixe a variável enviando como a trava real. O botão continua focável, parece desligado e o código segue ignorando o segundo envio.

Envio tradicional, sem fetch

Se o formulário faz o envio normal do navegador, há um detalhe: controles desligados não entram nos dados enviados. Se o botão tem name e value que o servidor lê, desligá-lo durante o submit pode tirar esse valor do envio. Nesse caso, mande a informação num campo oculto ou trave apenas com a variável.

Texto do rótulo

Diga o verbo da ação em andamento: "Enviando pedido…", "Salvando…", "Gerando boleto…". Evite "Aguarde", que não diz o que está acontecendo, e evite trocar o botão por um ícone sem texto, que some para o leitor de tela e muda a largura do botão, fazendo o layout pular.

Erros comuns

  • Travar só o clique do botão. O Enter num campo envia de novo, e o formulário cria o segundo pedido por outro caminho.
  • Religar o botão só no sucesso. No primeiro erro 500 ou queda de rede, o botão fica morto e a pessoa recarrega a página, perdendo o que digitou.
  • Limpar o formulário no erro. Quem perdeu o endereço inteiro por uma falha sua dificilmente preenche tudo de novo.
  • Gerar a chave de idempotência a cada tentativa. Cada tentativa vira um pedido diferente e a chave não protege nada.
  • Tratar tempo esgotado como falha certa. O pedido pode ter sido criado; "tente de novo" sem conferir produz exatamente a duplicata que você queria evitar.
  • Confiar só no front. Duas abas, JavaScript que não carregou ou um reenvio automático passam por cima do botão cinza.

Checklist

  • A trava está no evento submit, com uma variável que ignora o segundo envio.
  • O rótulo muda para um texto que diz o que está acontecendo.
  • Existe uma região de status que anuncia o erro.
  • A requisição tem limite de tempo explícito.
  • No erro, o botão volta e os campos continuam preenchidos.
  • A mesma chave de idempotência vai em todas as tentativas do mesmo pedido.
  • O banco tem índice único na chave e devolve o pedido existente na repetição.
  • Com a rede limitada nas ferramentas do navegador, o duplo clique gera um registro só.

Abra o formulário mais importante do seu sistema, limite a rede nas ferramentas de desenvolvedor e clique duas vezes rápido no botão de enviar. Nasceu um registro ou dois? Se nasceram dois, a sua espera ainda é só visual.

Avalie este artigo

Referência: http://carlos.inf.br/2012/07/27/jquery-waiting-deixando-o-tempo-carregamento-menos-constrangedor/