Voltar para Artigos
Front-end★ Destaque10 min de leitura

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).

12 de agosto de 2026

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

bash
npm install react-hook-form zod @hookform/resolvers
RegisterForm.tsx
import { 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.

Formulário com lista dinâmica de itens
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.

FormProvider para evitar prop drilling
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.