Skip to content

Como Construir Formularios com Validacao

Este tutorial mostra como construir um formulario de criacao/edicao com validacao Zod, useMutation e tratamento adequado de erros.

Cenario

Voce precisa de um formulario para criar novos produtos. Ele deve:

  • Validar os campos antes do envio
  • Exibir erros por campo
  • Gerenciar estados de carregamento e sucesso
  • Usar o adapter para converter os dados para a API

Arquitetura

Passo 1 — Definir o Schema de Validacao

typescript
// src/modules/products/types/products.schemas.ts

import { z } from 'zod'

export const createProductSchema = z.object({
  name: z
    .string()
    .min(3, 'O nome deve ter pelo menos 3 caracteres')
    .max(100, 'O nome deve ter no maximo 100 caracteres'),
  description: z
    .string()
    .min(10, 'A descricao deve ter pelo menos 10 caracteres')
    .max(500, 'Descricao muito longa'),
  category: z
    .string()
    .min(1, 'Por favor, selecione uma categoria'),
  price: z
    .number({ invalid_type_error: 'O preco deve ser um numero' })
    .positive('O preco deve ser positivo')
    .max(99999, 'Preco muito alto'),
  imageUrl: z
    .string()
    .url('Deve ser uma URL valida')
    .optional()
    .or(z.literal('')),
})

export type CreateProductFormData = z.infer<typeof createProductSchema>

Zod + Contracts

O schema Zod valida a entrada do formulario. O contrato em products.contracts.ts define o modelo de dados da aplicacao. Eles podem se sobrepor, mas servem a propositos diferentes.

Passo 2 — Construir um Composable de Validacao de Formulario

typescript
// src/shared/composables/useFormValidation.ts

import { ref, type Ref } from 'vue'
import type { ZodSchema, ZodError } from 'zod'

export function useFormValidation<T>(schema: ZodSchema<T>) {
  const errors: Ref<Record<string, string>> = ref({})
  const isValid = ref(false)

  function validate(data: unknown): data is T {
    try {
      schema.parse(data)
      errors.value = {}
      isValid.value = true
      return true
    } catch (err) {
      const zodError = err as ZodError
      errors.value = Object.fromEntries(
        zodError.errors.map(e => [e.path.join('.'), e.message])
      )
      isValid.value = false
      return false
    }
  }

  function clearErrors() {
    errors.value = {}
  }

  function getError(field: string): string | undefined {
    return errors.value[field]
  }

  return { errors, isValid, validate, clearErrors, getError }
}

Passo 3 — Construir o Composable de Mutation

typescript
// src/modules/products/composables/useCreateProduct.ts

import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { productsService } from '../services/products-service'
import { productsAdapter } from '../adapters/products-adapter'
import type { CreateProductInput } from '../types/products.contracts'

export function useCreateProduct() {
  const queryClient = useQueryClient()

  const { mutate, isPending, error, isSuccess, reset } = useMutation({
    mutationFn: (input: CreateProductInput) => {
      const payload = productsAdapter.toCreatePayload(input)
      return productsService.create(payload)
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] })
    },
  })

  return {
    createProduct: mutate,
    isPending,
    error,
    isSuccess,
    reset,
  }
}

Passo 4 — Construir o Componente de Formulario

vue
<!-- src/modules/products/components/ProductForm.vue -->
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useFormValidation } from '@/shared/composables/useFormValidation'
import { useCreateProduct } from '../composables/useCreateProduct'
import { createProductSchema, type CreateProductFormData } from '../types/products.schemas'

const router = useRouter()
const { createProduct, isPending, isSuccess, error: apiError } = useCreateProduct()
const { validate, getError, clearErrors } = useFormValidation(createProductSchema)

const form = reactive<CreateProductFormData>({
  name: '',
  description: '',
  category: '',
  price: 0,
  imageUrl: '',
})

const categories = [
  { value: 'electronics', label: 'Eletronicos' },
  { value: 'clothing', label: 'Roupas' },
  { value: 'books', label: 'Livros' },
  { value: 'home', label: 'Casa & Jardim' },
]

function handleSubmit() {
  clearErrors()

  if (!validate(form)) return

  createProduct({
    name: form.name,
    description: form.description,
    category: form.category,
    price: form.price,
    imageUrl: form.imageUrl || undefined,
  })
}

// Redireciona em caso de sucesso
watch(isSuccess, (success) => {
  if (success) {
    router.push({ name: 'products' })
  }
})
</script>

<template>
  <form @submit.prevent="handleSubmit" class="product-form">
    <h2>Criar Produto</h2>

    <!-- Banner de erro da API -->
    <div v-if="apiError" class="error-banner">
      Falha ao criar o produto. Por favor, tente novamente.
    </div>

    <!-- Nome -->
    <div class="field">
      <label for="name">Nome do Produto *</label>
      <input
        id="name"
        v-model="form.name"
        type="text"
        placeholder="ex: Fones de Ouvido Sem Fio"
        :class="{ invalid: getError('name') }"
      />
      <span v-if="getError('name')" class="field-error">
        {{ getError('name') }}
      </span>
    </div>

    <!-- Descricao -->
    <div class="field">
      <label for="description">Descricao *</label>
      <textarea
        id="description"
        v-model="form.description"
        rows="4"
        placeholder="Descreva o produto..."
        :class="{ invalid: getError('description') }"
      />
      <span v-if="getError('description')" class="field-error">
        {{ getError('description') }}
      </span>
    </div>

    <!-- Categoria -->
    <div class="field">
      <label for="category">Categoria *</label>
      <select
        id="category"
        v-model="form.category"
        :class="{ invalid: getError('category') }"
      >
        <option value="">Selecione uma categoria</option>
        <option
          v-for="cat in categories"
          :key="cat.value"
          :value="cat.value"
        >
          {{ cat.label }}
        </option>
      </select>
      <span v-if="getError('category')" class="field-error">
        {{ getError('category') }}
      </span>
    </div>

    <!-- Preco -->
    <div class="field">
      <label for="price">Preco (BRL) *</label>
      <input
        id="price"
        v-model.number="form.price"
        type="number"
        step="0.01"
        min="0"
        :class="{ invalid: getError('price') }"
      />
      <span v-if="getError('price')" class="field-error">
        {{ getError('price') }}
      </span>
    </div>

    <!-- URL da Imagem -->
    <div class="field">
      <label for="imageUrl">URL da Imagem (opcional)</label>
      <input
        id="imageUrl"
        v-model="form.imageUrl"
        type="url"
        placeholder="https://..."
        :class="{ invalid: getError('imageUrl') }"
      />
      <span v-if="getError('imageUrl')" class="field-error">
        {{ getError('imageUrl') }}
      </span>
    </div>

    <!-- Enviar -->
    <button type="submit" :disabled="isPending">
      {{ isPending ? 'Criando...' : 'Criar Produto' }}
    </button>
  </form>
</template>

Fluxo de Dados do Formulario

Modo de Edicao

Para reutilizar o mesmo formulario para edicao, adicione uma prop e pre-preencha:

vue
<script setup lang="ts">
import type { Product } from '../types/products.contracts'

const props = defineProps<{
  product?: Product // undefined = criacao, definido = edicao
}>()

const form = reactive<CreateProductFormData>({
  name: props.product?.name ?? '',
  description: props.product?.description ?? '',
  category: props.product?.category ?? '',
  price: props.product?.price ?? 0,
  imageUrl: props.product?.imageUrl ?? '',
})

// Usa mutation diferente para edicao
const { createProduct, isPending } = props.product
  ? useUpdateProduct(props.product.id)
  : useCreateProduct()
</script>

Pontos-Chave

  • Zod valida na fronteira do formulario — antes dos dados entrarem no sistema
  • Adapter converte na fronteira da API — antes dos dados sairem do sistema
  • useMutation gerencia carregamento, erro e invalidacao de cache
  • Componentes exibem estado (carregamento, erros, sucesso) — sem logica de negocio

Proximos Passos

Distribuído sob a licença MIT.