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_Prefixodo 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
Controllerdireto quandoFormFieldbastaria — prefiraFormFielde reserveControllerpara 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
|
No Comments