11 — Formulários e validação

11 — Formulários e validação

react-hook-form + zod, sempre via useValidatedForm.

Schema é a fonte única do formato: o tipo do formulário é inferido dele, nunca escrito à mão.

Campos do formulário usam os mesmos nomes _Prefixo do backend.

useValidatedForm

// src/hooks/useValidatedForm.ts

import { useForm, UseFormProps, UseFormReturn } from "react-hook-form";

import { zodResolver } from "@hookform/resolvers/zod";

import { z } from "zod";

interface UseValidatedFormProps<T extends z.ZodType> extends Omit<UseFormProps<z.infer<T>>, "resolver"> {

 schema: T;

}

export const useValidatedForm = <T extends z.ZodType>({

 schema,

 ...formProps

}: UseValidatedFormProps<T>): UseFormReturn<z.infer<T>> =>

 useForm<z.infer<T>>({

 resolver: zodResolver(schema),

 mode: "onChange", // valida em tempo real

 ...formProps

 });

Use sempre este hook, nunca useForm direto. Ele garante o resolver e o mode: "onChange" uniformes.

mode: "onChange" valida a cada digitação. Para formulário grande e caro de validar, sobrescreva

para "onBlur" — o spread permite.

schemas.ts

Um arquivo por tela, ao lado do componente.

// src/pages/Fornecedor/Cadastro/schemas.ts

import { z } from "zod";

export const fornecedorSchema = z.object({

 _Nome: z.string().min(1, "Informe o nome").max(120, "Máximo de 120 caracteres"),

 _CNPJ: z

 .string()

 .min(1, "Informe o CNPJ")

 .regex(/^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/, "CNPJ inválido"),

 _Email: z.string().email("E-mail inválido").optional().or(z.literal("")),

 _Ativo: z.boolean(),

 _DataCadastro: z.string().min(1, "Informe a data")

});

export type FornecedorFormData = z.infer<typeof fornecedorSchema>;

export const filtroFornecedorSchema = z.object({

 _Nome: z.string(),

 _Ativo: z.boolean()

});

export type FiltroFornecedorData = z.infer<typeof filtroFornecedorSchema>;

Regras:

Nunca escreva a interface do formulário à mão. Sempre z.infer<typeof schema>. Escrever as duas

cria divergência silenciosa.

Toda regra tem mensagem em português — ela vai direto para a tela.

Nomes de campo espelham o backend (_Nome), evitando mapeamento no submit.

Um schema por formulário. Tela com dois formulários independentes tem dois schemas.

Campo opcional que aceita vazio

_Email: z.string().email("E-mail inválido").optional().or(z.literal(""));

.optional() sozinho não basta: um TextField limpo devolve "", não undefined, e "" falha na

validação de e-mail.

O formulário

Duas formas, ambas válidas.

Com FormField (preferível)

import { FormField } from "@components/common";

<FormField name="_Nome" control={form.control} label="Nome" size="small" fullWidth />;

Encapsula o Controller e liga error/helperText ao fieldState. Use quando o campo é um

TextField comum.

Com Controller (quando precisa do componente MUI cru)

<Controller

 name="_Nome"

 control={form.control}

 render={({ field, fieldState }) => (

 <TextField

 {...field}

 label="Nome"

 size="small"

 fullWidth

 error={!!fieldState.error}

 helperText={fieldState.error?.message}

 />

 )}

/>

Mais verboso, mas necessário quando o componente não é um TextField ou exige props que o FormField

não repassa.

Evite usar Controller direto quando FormField bastaria — prefira FormField e reserve

Controller para os casos que realmente precisam do componente MUI cru.

Submit

const form = useValidatedForm({

 schema: fornecedorSchema,

 defaultValues: { _Nome: "", _CNPJ: "", _Ativo: true, _DataCadastro: dataHoje }

});

const handleSalvar = async (data: FornecedorFormData) => {

 await salvarAsync({

 Fornecedor: {

 _OID: String(fornecedorData?.Fornecedor._OID ?? ""),

 _Nome: data._Nome,

 _CNPJ: data._CNPJ

 },

 msgSucesso: "Fornecedor salvo com sucesso!"

 });

};

<Button onClick={form.handleSubmit(handleSalvar)}>Salvar</Button>;

form.handleSubmit(fn) só chama fn se a validação passar. Não valide manualmente antes — é o

que o resolver faz.

defaultValues é obrigatório

Sempre forneça defaultValues com todos os campos. Sem ele, o campo nasce não-controlado e vira

controlado na primeira digitação — o React emite warning e o reset() não limpa direito.

Reset

const handleNovoRegistro = () => {

 form.reset(); // volta aos defaultValues

 filtroForm.reset({ _Nome: "", _Ativo: true }); // valores explicitos

};

Inputs com máscara

CnpjInput, PhoneInput, CurrencyInput e DatePickerInput de common/ — ver

08.

Moeda precisa de conversão no submit:

import { CurrencyInput, parseCurrencyValue } from "@components/common";

const handleSalvar = (data: FormData) => {

 salvar({ Fornecedor: { _Limite: parseCurrencyValue(data._Limite) } });

};

O campo guarda o texto formatado ("1.234,56"); o backend quer número. parseCurrencyValue faz a

ponte. Esquecer isso envia string e o backend rejeita.

Datas

O backend Curio espera data com tempo:

const dataFormatada = `${data._DataExpiracao}T00:00:00.0`;

O campo do formulário guarda "2026-08-18"; o request precisa de "2026-08-18T00:00:00.0". Faça a

conversão no handler, não no schema — o schema descreve o formulário, não o payload.

Para gerar a data de hoje no formato do input:

const hoje = new Date();

const dataHoje = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, "0")}-${String(hoje.getDate()).padStart(2, "0")}`;

Não use toISOString().split("T")[0] — converte para UTC e retorna o dia anterior à noite no

fuso brasileiro.

Validação vinda do servidor

O backend valida de novo. Um ValidationError do Curio traz detail: string[], já convertido em

notificação pelo queryClient (07).

Não tente espelhar toda regra de servidor no zod. O zod cobre o que dá para verificar no cliente

(obrigatório, formato, tamanho); regra de negócio é do servidor.

Erros comuns

Sintoma

Causa

"changing an uncontrolled input"

Faltou o campo em defaultValues

reset() não limpa

Idem

Submit não dispara e nada aparece

Validação falhou num campo sem FormField/helperText visível

Backend rejeita a data

Faltou o sufixo T00:00:00.0

Backend recebe moeda como texto

Faltou parseCurrencyValue

Tipo do form diverge do schema

Interface escrita à mão em vez de z.infer

