Formulários de Alta Performance: React Hook Form + Zod
Por que uncontrolled components eliminam re-renders. Zod com refinements e superRefine para validações cruzadas. useFieldArray para listas dinâmicas, FormProvider para formulários aninhados e integração com componentes de UI externos (shadcn/ui).
Um formulário de cadastro com 10 campos usando useState por campo gera até 10 re-renders por keystroke — 10 atualizações de DOM a cada letra digitada. Em dispositivos lentos, isso se traduz em travamento visível. A causa é usar controlled components onde o React gerencia o valor de cada input.
React Hook Form adota a filosofia inversa: uncontrolled components. O DOM nativo guarda os valores. O React Hook Form lê esses valores na hora do submit — sem re-renders durante a digitação. Combinado com Zod para validação com inferência de tipo, o resultado é formulários zero-boilerplate, performáticos e totalmente type-safe.
Formulário com Validação Básica
npm install react-hook-form zod @hookform/resolversimport { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
// Schema = regras de validação + fonte do tipo TypeScript
const RegisterSchema = z
.object({
name: z.string().min(3, 'Nome deve ter ao menos 3 caracteres'),
email: z.string().email('E-mail inválido').toLowerCase(),
password: z
.string()
.min(8, 'Mínimo 8 caracteres')
.regex(/[A-Z]/, 'Deve conter ao menos uma maiúscula')
.regex(/[0-9]/, 'Deve conter ao menos um número'),
confirmPassword: z.string(),
role: z.enum(['USER', 'MANAGER', 'ADMIN']).default('USER'),
terms: z.literal(true, {
errorMap: () => ({ message: 'Você deve aceitar os termos.' }),
}),
})
// superRefine: validações cruzadas entre campos
.superRefine(({ password, confirmPassword }, ctx) => {
if (password !== confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'As senhas não coincidem.',
path: ['confirmPassword'], // Aponta o erro para o campo correto
});
}
});
// Tipo inferido do schema — sem duplicação de interface manual
type RegisterFormData = z.infer<typeof RegisterSchema>;
export function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
setError, // Para erros vindos da API
reset,
} = useForm<RegisterFormData>({
resolver: zodResolver(RegisterSchema),
mode: 'onBlur', // Valida quando o usuário sai do campo (não a cada tecla)
});
async function onSubmit(data: RegisterFormData) {
try {
await api.post('/users', data);
reset(); // Limpa o formulário após sucesso
} catch (err) {
// Integra erros da API com o formulário
if (isApiError(err) && err.field) {
setError(err.field as keyof RegisterFormData, {
message: err.message,
});
}
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">Nome</label>
<input
id="name"
{...register('name')}
aria-invalid={!!errors.name}
aria-describedby="name-error"
/>
{errors.name && (
<span id="name-error" role="alert" className="text-red-500">
{errors.name.message}
</span>
)}
</div>
{/* Repita para email, password, confirmPassword */}
<label className="flex items-center gap-2">
<input type="checkbox" {...register('terms')} />
Aceito os termos de uso
</label>
{errors.terms && <span role="alert">{errors.terms.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Cadastrando...' : 'Criar conta'}
</button>
</form>
);
}useFieldArray: Listas Dinâmicas
O zodResolver é a ponte entre o Zod e o React Hook Form: passa o schema como fonte de validação, elimina a necessidade de escrever regras de validação nos register(), e garante que o tipo RegisterFormData inferido pelo Zod seja o mesmo tipo que o formulario retorna no handleSubmit. O mode: 'onBlur' é o equilíbrio ideal: valida quando o usuário sai do campo, não a cada tecla pressionada, evitando mensagens de erro prematuras enquanto o usuário ainda está digitando.
import { useForm, useFieldArray } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const OrderSchema = z.object({
customerId: z.string().uuid(),
items: z
.array(
z.object({
productId: z.string().uuid(),
quantity: z.coerce.number().int().min(1, 'Mínimo 1'),
unitPrice: z.coerce.number().positive(),
})
)
.min(1, 'Adicione ao menos um item'),
notes: z.string().optional(),
});
type OrderFormData = z.infer<typeof OrderSchema>;
export function OrderForm() {
const { register, control, handleSubmit, formState: { errors } } =
useForm<OrderFormData>({
resolver: zodResolver(OrderSchema),
defaultValues: { items: [{ productId: '', quantity: 1, unitPrice: 0 }] },
});
// useFieldArray: gerencia o array de items
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}> {/* field.id é o ID interno do RHF — não use index */}
<input
{...register(`items.${index}.productId`)}
placeholder="ID do produto"
/>
<input
{...register(`items.${index}.quantity`)}
type="number"
/>
{errors.items?.[index]?.quantity && (
<span>{errors.items[index].quantity?.message}</span>
)}
<button type="button" onClick={() => remove(index)}>Remover</button>
</div>
))}
<button
type="button"
onClick={() => append({ productId: '', quantity: 1, unitPrice: 0 })}
>
+ Adicionar item
</button>
<button type="submit">Criar pedido</button>
</form>
);
}FormProvider: Formulários com Componentes Aninhados
O useFieldArray é a solução para formulários com listas dinâmicas sem re-renders excessivos. A chave é usar field.id como key do React (nunca o index) — o RHF gera IDs estavéis que sobrevivem a remoções e reordenações. Usar index como key faz o React renderizar o componente errado após remoção de um item do meio da lista, causando bugs sutis onde os valores do input ficam associados ao item errado.
import { FormProvider, useForm, useFormContext } from 'react-hook-form';
// Componente filho usa useFormContext — sem prop drilling do register
function AddressSection() {
const { register, formState: { errors } } = useFormContext<CheckoutFormData>();
return (
<fieldset>
<legend>Endereço de entrega</legend>
<input {...register('address.street')} placeholder="Rua" />
{errors.address?.street && <span>{errors.address.street.message}</span>}
<input {...register('address.zipCode')} placeholder="CEP" />
</fieldset>
);
}
// Componente pai envolve tudo com FormProvider
export function CheckoutForm() {
const methods = useForm<CheckoutFormData>({
resolver: zodResolver(CheckoutSchema),
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<PersonalSection /> {/* Acessa register via useFormContext */}
<AddressSection /> {/* Sem prop drilling */}
<PaymentSection /> {/* Cada seção é isolada e reutilizável */}
<button type="submit">Finalizar pedido</button>
</form>
</FormProvider>
);
}Use mode: 'onBlur' em formulários longos (valida quando o usuário sai do campo) e mode: 'onChange' apenas em campos onde feedback imediato é crucial (ex: confirmação de senha). O padrão mode: 'onSubmit' valida apenas no submit — pode frustrar o usuário ao mostrar todos os erros de uma vez.
Conclusão
React Hook Form + Zod elimina três problemas de uma vez: performance (zero re-renders durante digitação), boilerplate (sem useState por campo, sem handlers manuais), e type safety (tipo inferido do schema sem duplicação). O superRefine resolve validações cruzadas. O useFieldArray torna listas dinâmicas triviais. E o FormProvider organiza formulários complexos sem prop drilling.