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
- Escute o
submitdo formulário e use uma variável de controle (enviando). Se ela já estiver ligada, ignore o evento. - 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ê.
- 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. - Ponha um limite de tempo na requisição. Sem limite, "esperando" pode durar o tempo que a bateria do celular aguentar.
- No erro, religue o botão, volte o rótulo original e diga o que fazer em seguida. Não limpe os campos.
- 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.
Referência: http://carlos.inf.br/2012/07/27/jquery-waiting-deixando-o-tempo-carregamento-menos-constrangedor/