Programação Por Equipe DevBras

Capa do artigo: Formulário em Vue que valida na hora certa e não perde o erro do servidor

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

  1. Guarde os valores num objeto reativo. Um reactive com nome, e-mail e mensagem. Na hora de enviar, você manda esse objeto inteiro, sem procurar valor no DOM.
  2. 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.
  3. 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.
  4. Use rótulo de verdade. Cada input tem um label com for apontando para o id do campo. Placeholder não é rótulo, como explica o guia sobre placeholder que não substitui o rótulo.
  5. 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.
  6. 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.
  7. 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 .prevent no 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 blur ou da primeira tentativa de envio.
  • Cada campo tem label associado e mensagem ligada por aria-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á?

Avalie este artigo

Referência: https://mdbootstrap.com/docs/vue/forms/overview/