Capítulo 10: Formulários Avançados e Validação com Vee-Validate e Zod

Especialização em Front-End com Vue • Vue 3 & Nuxt 3 • Composition API, Pinia, Vue Router, SSR e TypeScript


🗺️ Mapa Conceitual da Validação Baseada em Esquemas

flowchart TD
    A["Contrato de Domínio: Schema Zod (z.object)"] --> B["toTypedSchema(@vee-validate/zod)"]
    B --> C["useForm() do Vee-Validate 4"]
    C --> D["defineField(): Vincula Inputs a Flags Reativas"]
    C --> E["handleSubmit(): Validação Antes do Envio"]

    D --> F["meta.valid / meta.dirty / errors"]
    E -->|Válido| G["Chamada à API REST / Backend"]
    E -->|Inválido| H["Exibição de Mensagens de Erro por Campo"]
    G --> I["resetForm() ou Mapeamento de Erros da API"]

    style A fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px
    style B fill:#fff3e0,stroke:#ff9800,stroke-width:2px
    style C fill:#ede7f6,stroke:#7e57c2,stroke-width:2px
    style G fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
    style H fill:#fce4ec,stroke:#e91e63,stroke-width:2px

🏛️ 1. Arquitetura de Formulários Corporativos no Vue 3

Em sistemas corporativos, formulários lidam com dezenas de campos, regras de validação interdependentes, estados de carregamento e mensagens de erro do backend. Gerenciar esse ecossistema com múltiplos ref() manuais gera código frágil e duplicado.

A solução canônica no ecossistema Vue 3 é a combinação de Vee-Validate 4 (para gerenciamento do ciclo de vida, rastreamento de dirty/touched e submissões) com Zod (para validação estrita baseada em esquemas e inferência de tipos TypeScript).

1.1 O Princípio Schema-First com Zod

No paradigma Schema-First, toda regra de negócio do formulário é descrita em um único objeto Zod imutável e reutilizável:

import * as z from 'zod';

export const userRegistrationSchema = z.object({
  fullName: z.string().min(3, 'Mínimo de 3 caracteres').max(60),
  email: z.string().email('E-mail corporativo inválido'),
  role: z.enum(['ADMIN', 'OPERATOR', 'VIEWER']),
  salaryExpectation: z.number().positive('O valor deve ser positivo')
});

// Inferência automática do contrato de dados:
export type UserRegistrationDTO = z.infer<typeof userRegistrationSchema>;

Isso garante que a mesma regra possa ser utilizada tanto no frontend quanto em testes de integração e até mesmo em endpoints backend no Node.js/Nitro.

1.2 O Composable useForm e a Macro defineField

No Vee-Validate 4.10+, o método defineField substitui componentes wrappers verbosos (<Field> e <ErrorMessage>). Ele gera um par com a Ref do valor e um objeto de propriedades prontas para serem vinculadas com v-bind:

const [email, emailProps] = defineField('email');
// No template:
<input v-model="email" v-bind="emailProps" />

O emailProps escuta os eventos input e blur nativos para atualizar os estados meta.touched e meta.dirty sem necessidade de código manual.

1.3 A Máquina de Estados do Formulário: meta

O objeto meta do useForm expõe flags reativas fundamentais para UX profissional:

  • meta.valid: Indica se 100% dos campos atendem ao schema Zod.
  • meta.dirty: Indica se o usuário alterou algum valor em relação aos dados originais (initialValues).
  • meta.touched: Indica se o usuário já interagiu com o campo (saiu do foco via blur).
  • isSubmitting: Flag booleana que se torna true durante a execução da Promise assíncrona do handleSubmit.

⚖️ 2. Tabela Comparativa: Validação Manual vs Vee-Validate + Zod

Característica Validação Manual com ref() Vee-Validate 4 + Zod
Linhas de Código de Boilerplate Elevadíssimo; múltiplos ternários e flags booleanas. Mínimo; schema declarativo centralizado.
Segurança de Tipos (TypeScript) Manual; suscetível a erros de digitação de campos. Automatizada com z.infer<typeof schema>.
Rastreamento de Estados (Dirty/Touched) Muito complexo de implementar manualmente. Nativo através do objeto reativo meta.
Prevenção de Múltiplos Cliques Exige criação manual de flags isSubmitting. Automática durante a execução de handleSubmit.
Reaproveitamento de Regras Baixo; regras acopladas ao componente SFC. Alto; schemas Zod podem ser compartilhados entre pacotes.

⚠️ 3. Armadilhas Comuns em Produção (Gotchas & Anti-Patterns)

Anti-Pattern 1: Disparar Erros Antes do Usuário Interagir (Aggressive Validation)

Erro Comum: Mostrar mensagens de erro em vermelho logo na primeira renderização da página, antes do usuário tocar no campo. Abordagem Correta: Condicione a exibição visual do erro à flag meta.touched ou utilize a validação padrão ao submeter.

Anti-Pattern 2: Esquecer de Chamar toTypedSchema()

Erro Comum: Passar o schema Zod cru diretamente para o Vee-Validate (validationSchema: meuSchemaZod). Abordagem Correta: O Vee-Validate é agnóstico a bibliotecas de schema. Use sempre o adaptador oficial: validationSchema: toTypedSchema(meuSchemaZod).


💻 4. Código Conceitual Arquitetural Comentado

<!-- src/components/CreateProjectForm.vue -->
<script setup lang="ts">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import * as z from 'zod';

const projectSchema = z.object({
  title: z.string().min(5, 'Título deve possuir pelo menos 5 caracteres'),
  budget: z.number({ invalid_type_error: 'Informe um valor numérico' }).min(1000, 'Orçamento mínimo de R$ 1.000'),
  priority: z.enum(['LOW', 'MEDIUM', 'HIGH'])
});

type ProjectDTO = z.infer<typeof projectSchema>;

const { defineField, handleSubmit, errors, isSubmitting, meta, resetForm } = useForm<ProjectDTO>({
  validationSchema: toTypedSchema(projectSchema),
  initialValues: { title: '', budget: 5000, priority: 'MEDIUM' }
});

const [title, titleProps] = defineField('title');
const [budget, budgetProps] = defineField('budget');
const [priority, priorityProps] = defineField('priority');

const onSubmit = handleSubmit(async (values) => {
  // Simulação de chamada de API backend
  await new Promise((resolve) => setTimeout(resolve, 800));
  console.log('[API] Projeto criado com sucesso:', values);
  resetForm();
});
</script>

<template>
  <form @submit="onSubmit" class="max-w-md mx-auto p-6 bg-white border rounded-2xl shadow-sm space-y-4">
    <h3 class="font-bold text-slate-800 text-lg">Novo Projeto Corporativo</h3>

    <div>
      <label class="block text-xs font-semibold text-slate-700 mb-1">Título do Projeto:</label>
      <input
        type="text"
        v-model="title"
        v-bind="titleProps"
        class="w-full border px-3 py-2 rounded-lg text-sm"
        :class="errors.title ? 'border-red-500 bg-red-50' : 'border-slate-300'"
      />
      <span v-if="errors.title" class="text-xs text-red-600 mt-1 block">{{ errors.title }}</span>
    </div>

    <div>
      <label class="block text-xs font-semibold text-slate-700 mb-1">Orçamento (R$):</label>
      <input
        type="number"
        v-model.number="budget"
        v-bind="budgetProps"
        class="w-full border px-3 py-2 rounded-lg text-sm"
      />
      <span v-if="errors.budget" class="text-xs text-red-600 mt-1 block">{{ errors.budget }}</span>
    </div>

    <button
      type="submit"
      :disabled="isSubmitting || !meta.valid"
      class="w-full py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-bold transition-colors disabled:opacity-50"
    >
      {{ isSubmitting ? 'Salvando...' : 'Cadastrar Projeto' }}
    </button>
  </form>
</template>

🛠️ 5. Cheat-Sheet do Vee-Validate + Zod

import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import * as z from 'zod';

// 1. Definição do Schema
const schema = z.object({
  email: z.string().email(),
  idade: z.number().min(18)
});

// 2. Inicialização do Form
const { defineField, handleSubmit, errors, meta, resetForm } = useForm({
  validationSchema: toTypedSchema(schema)
});

// 3. Binding de Campo
const [email, emailAttrs] = defineField('email');

// 4. Handler com Validação Embutida
const salvar = handleSubmit((values) => {
  // Executado apenas se for 100% válido
});