Formulário em Vue que valida na hora certa e não perde o erro do servidor
Monte um formulário em Vue 3 com um estado por campo, validação que não grita antes da hora, foco no primeiro erro e tratamento de sucesso, erro 422 e falha de rede.
A pessoa digita a primeira letra do e-mail e o campo já fica vermelho com "e-mail inválido". Ela termina, clica em enviar, o botão gira e nada acontece: a API recusou, mas a tela não mostrou o motivo. A resposta curta: um formulário em Vue fica sólido quando você separa três coisas. Estado (um objeto com o valor de cada campo, ligado por v-model), validação (uma função pura que recebe esse objeto e devolve os erros por campo) e envio (que trata sucesso, erro de validação do servidor e falha de rede como três caminhos diferentes). O kit visual é detalhe; esse contrato é o que faz o formulário funcionar.
O exemplo que atravessa este guia é o formulário de orçamento de uma marcenaria: nome, e-mail e uma mensagem descrevendo o móvel. Três campos parecem pouco, mas já mostram todos os problemas que aparecem num cadastro de vinte.
Por que formulário em Vue costuma dar errado
O Vue facilita ligar um input a uma variável, e essa facilidade esconde as decisões que importam. Quando a validação mora espalhada no template, com um v-if e uma regra diferente em cada campo, ninguém sabe qual é a regra completa. Quando o erro aparece a cada tecla, a pessoa é punida por algo que ainda não terminou de fazer.
O terceiro problema é o mais caro: tratar a resposta da API como "deu certo" ou "deu errado". O servidor pode recusar um e-mail já cadastrado, pode cair, pode demorar. Se tudo isso vira o mesmo alerta genérico, a pessoa tenta de novo, envia duas vezes ou desiste.
Como montar o formulário passo a passo
- Guarde os valores num objeto reativo. Um
reactivecom nome, e-mail e mensagem. Na hora de enviar, você manda esse objeto inteiro, sem procurar valor no DOM. - Escreva a validação como função pura. Ela recebe os dados e devolve um objeto do tipo campo e mensagem. Função pura é fácil de testar e de reaproveitar.
- Decida quando o erro aparece. Depois que o campo perde o foco pela primeira vez ou depois da primeira tentativa de envio. Antes disso, silêncio.
- Use rótulo de verdade. Cada input tem um
labelcomforapontando para oiddo campo. Placeholder não é rótulo, como explica o guia sobre placeholder que não substitui o rótulo. - No envio, valide de novo e leve o foco ao primeiro erro. Quem usa teclado ou leitor de tela precisa chegar ao problema sem caçar.
- Trate três respostas. Sucesso com mensagem explícita, erro 422 com mensagens por campo e falha de rede com convite para tentar de novo, sem apagar o que a pessoa digitou.
- Bloqueie o botão só durante a requisição. Se a validação falhar, o botão continua ativo.
O componente completo em Vue 3
O código abaixo usa a Composition API com script setup. Ele não depende de biblioteca de validação nem de kit visual.
<script setup>
import { reactive, ref, computed, nextTick } from 'vue'
const form = reactive({ nome: '', email: '', mensagem: '' })
const tocados = reactive({ nome: false, email: false, mensagem: false })
const errosServidor = reactive({})
const tentouEnviar = ref(false)
const enviando = ref(false)
const status = ref('')
function validar(dados) {
const erros = {}
if (!dados.nome.trim()) erros.nome = 'Informe seu nome.'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(dados.email.trim())) {
erros.email = 'Informe um e-mail no formato nome@dominio.com.'
}
if (dados.mensagem.trim().length < 20) {
erros.mensagem = 'Descreva o móvel em pelo menos 20 caracteres.'
}
return erros
}
const erros = computed(() => validar(form))
function erroDe(campo) {
if (errosServidor[campo]) return errosServidor[campo]
if (tentouEnviar.value || tocados[campo]) return erros.value[campo] || ''
return ''
}
function limparServidor(campo) {
errosServidor[campo] = ''
}
async function focarPrimeiroErro() {
await nextTick()
const campo = ['nome', 'email', 'mensagem'].find((c) => erroDe(c))
if (campo) document.getElementById('campo-' + campo).focus()
}
async function enviar() {
tentouEnviar.value = true
status.value = ''
if (Object.keys(erros.value).length) {
await focarPrimeiroErro()
return
}
enviando.value = true
try {
const resp = await fetch('/api/orcamentos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
if (resp.status === 422) {
const corpo = await resp.json()
Object.assign(errosServidor, corpo.erros)
await focarPrimeiroErro()
return
}
if (!resp.ok) throw new Error('HTTP ' + resp.status)
status.value = 'Pedido de orçamento enviado. Respondemos em até dois dias úteis.'
Object.assign(form, { nome: '', email: '', mensagem: '' })
Object.assign(tocados, { nome: false, email: false, mensagem: false })
tentouEnviar.value = false
} catch (e) {
status.value = 'Não conseguimos enviar agora. Seus dados continuam aqui; tente de novo em instantes.'
} finally {
enviando.value = false
}
}
</script>
<template>
<form novalidate @submit.prevent="enviar">
<label for="campo-nome">Nome (obrigatório)</label>
<input id="campo-nome" v-model="form.nome" autocomplete="name"
:aria-invalid="erroDe('nome') ? 'true' : 'false'"
aria-describedby="erro-nome"
@blur="tocados.nome = true" @input="limparServidor('nome')">
<p id="erro-nome">{{ erroDe('nome') }}</p>
<label for="campo-email">E-mail (obrigatório)</label>
<input id="campo-email" v-model="form.email" type="email" autocomplete="email"
:aria-invalid="erroDe('email') ? 'true' : 'false'"
aria-describedby="erro-email"
@blur="tocados.email = true" @input="limparServidor('email')">
<p id="erro-email">{{ erroDe('email') }}</p>
<label for="campo-mensagem">O que você quer construir (obrigatório)</label>
<textarea id="campo-mensagem" v-model="form.mensagem" rows="5"
:aria-invalid="erroDe('mensagem') ? 'true' : 'false'"
aria-describedby="erro-mensagem"
@blur="tocados.mensagem = true" @input="limparServidor('mensagem')"></textarea>
<p id="erro-mensagem">{{ erroDe('mensagem') }}</p>
<button type="submit" :disabled="enviando">
{{ enviando ? 'Enviando...' : 'Pedir orçamento' }}
</button>
<p role="status">{{ status }}</p>
</form>
</template>
O que cada decisão resolve
O atributo novalidate desliga os balões nativos do navegador, para que só a sua mensagem apareça, no seu texto e no lugar certo. O type="email" continua útil: no celular, ele abre o teclado com arroba.
A função erroDe concentra a regra de exibição. O erro do servidor vence, porque é a palavra final. O erro local só aparece depois do primeiro blur ou da primeira tentativa de envio. Quando a pessoa volta a digitar, o erro do servidor some, porque ele se referia ao valor antigo.
O parágrafo com role="status" faz o leitor de tela anunciar o resultado sem roubar o foco. E a mensagem de sucesso diz o que aconteceu e o que esperar, não só pinta o botão de verde.
O contrato com o servidor
A validação no navegador é conforto. A regra que vale roda na API, porque qualquer pessoa pode mandar um POST sem passar pela tela. O que torna os dois lados coerentes é um formato de erro combinado. Neste exemplo, a API responde com status 422 e um corpo assim:
{
"erros": {
"email": "Já existe um pedido aberto com este e-mail. Responda o e-mail que enviamos."
}
}
O componente faz Object.assign desse objeto em errosServidor e cada mensagem cai embaixo do campo certo. Se a frase da tela e a da API divergem para a mesma regra, a pessoa acha que errou duas vezes; o guia sobre mensagem de erro igual na tela e na API mostra como manter um texto só.
Do lado do servidor, o mesmo cuidado de qualquer formulário vale: limite de tamanho, limite de envios por origem e nada de confiar em campo oculto. Se o destino final é uma caixa de e-mail, veja também o formulário de contato que chega.
Erros comuns
- Validar a cada tecla desde o primeiro caractere. O campo fica vermelho enquanto a pessoa ainda digita, e ela aprende a ignorar o vermelho.
- Desabilitar o botão enquanto o formulário é inválido. A pessoa clica, nada acontece e nenhuma mensagem diz o que falta.
- Tratar qualquer resposta diferente de 200 como "erro". E-mail duplicado e servidor fora do ar recebem o mesmo alerta, e ninguém sabe se deve corrigir ou esperar.
- Limpar o formulário quando a rede falha. A pessoa perde a descrição de três parágrafos e não volta.
- Esquecer
.preventno submit. A página recarrega, o estado do Vue some e a requisição pode nem sair. - Usar placeholder como única identificação do campo. Depois de preenchido, não dá para saber o que cada caixa perguntava.
Checklist
- Todos os valores vivem num único objeto reativo, enviado inteiro.
- A validação é uma função que recebe os dados e devolve erros por campo.
- Nenhum erro aparece antes do primeiro
blurou da primeira tentativa de envio. - Cada campo tem
labelassociado e mensagem ligada poraria-describedby. - Ao falhar a validação, o foco vai para o primeiro campo com erro.
- Resposta 422 mostra a mensagem do servidor no campo certo.
- Falha de rede mantém os dados digitados e explica o que fazer.
- O botão fica desabilitado só durante a requisição.
Abra o formulário mais usado do seu sistema, desligue a rede no DevTools e clique em enviar. O que a pessoa vê, e o que ela digitou continua lá?
Referência: https://mdbootstrap.com/docs/vue/forms/overview/