Padrões: field arrays, controlled vs uncontrolled, condicionais
5 min de leitura
Os nós 2 e 3 cobrem o "caminho feliz" - 1 input, 1 register, validação via Zod. Em produção, você esbarra em três padrões que viram dor de cabeça se você não souber o atalho: listas dinâmicas (adicionar/remover items - ex: telefones de contato), campos condicionais (um campo que aparece só se outro for marcado), e inputs controlados por libs de UI (MUI, Chakra, shadcn, react-select). Este nó cobre os três.
O essencial 🟢
useFieldArray pra listas dinâmicas. Quando o
usuário precisa adicionar/remover/reordenar items
(linhas de pedido, telefones, endereços, dependentes),
o useFieldArray é o hook certo. Ele cuida do array
interno e dá append, remove, move, insert,
update, replace prontos.
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
nome: z.string().min(1),
telefones: z
.array(
z.object({
numero: z.string().min(10, "Telefone inválido"),
tipo: z.enum(["celular", "fixo", "trabalho"]),
})
)
.min(1, "Adicione pelo menos 1 telefone"),
});
type FormData = z.infer<typeof schema>;
function Cadastro() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { nome: "", telefones: [{ numero: "", tipo: "celular" }] },
});
const { fields, append, remove, move } = useFieldArray({
control,
name: "telefones",
});
return (
<form onSubmit={handleSubmit((d) => console.log(d))}>
<input {...register("nome")} placeholder="Nome" />
<h3>Telefones</h3>
{fields.map((field, index) => (
<div key={field.id}>
{/* IMPORTANTE: key={field.id}, não index. */}
<input {...register(`telefones.${index}.numero` as const)} />
<select {...register(`telefones.${index}.tipo` as const)}>
<option value="celular">Celular</option>
<option value="fixo">Fixo</option>
<option value="trabalho">Trabalho</option>
</select>
<button type="button" onClick={() => remove(index)}>
Remover
</button>
{index > 0 && (
<button type="button" onClick={() => move(index, index - 1)}>
Subir
</button>
)}
</div>
))}
{errors.telefones?.message && (
<span role="alert">{errors.telefones.message}</span>
)}
{errors.telefones?.[0]?.numero && (
<span role="alert">{errors.telefones[0].numero.message}</span>
)}
<button type="button" onClick={() => append({ numero: "", tipo: "celular" })}>
+ Adicionar telefone
</button>
<button type="submit">Salvar</button>
</form>
);
}
Os detalhes que importam:
key={field.id}- RHF dá umidestável pra cada item. Use ele nokey, nãoindex. Se você usarindexe reordenar, o React vai remontar os inputs e perder foco.namecom dot notation -`telefones.${index}.numero`é como o RHF sabe qual campo é qual. Funciona com Zod sem adaptação.appendaceita o objeto do item - mesma forma que o schema espera.remove(index)remove um item - o array se reordena, e okeyestável mantém o estado certo.move(from, to)- reordena. Útil pra "subir/ descer" ou drag-and-drop.errors.telefones?.[0]?.numero- erro de campo aninhado. Zod retornaerrors.telefones[0].numero.message.
watch pra campos condicionais. Quando um campo
só deve aparecer se outro for marcado, use watch
pra observar o valor e renderizar condicionalmente:
const { register, watch } = useForm<FormData>();
const tipoPessoa = watch("tipo");
return (
<div>
<select {...register("tipo")}>
<option value="">Selecione</option>
<option value="PF">Pessoa Física</option>
<option value="PJ">Pessoa Jurídica</option>
</select>
{tipoPessoa === "PF" && (
<input {...register("cpf")} placeholder="CPF" />
)}
{tipoPessoa === "PJ" && (
<>
<input {...register("cnpj")} placeholder="CNPJ" />
<input {...register("razaoSocial")} placeholder="Razão Social" />
</>
)}
</div>
);
watch re-renderiza a cada mudança do campo. É o
custo de observar. Em forms grandes com muitos campos
condicionais, considere mover o campo condicional
pra um sub-componente e usar useFormContext (ver
adiante).
Validação condicional no schema. O watch na UI
vira superRefine no schema (nó 3 já cobriu):
const schema = z
.object({
tipo: z.enum(["PF", "PJ"]),
cpf: z.string().optional(),
cnpj: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.tipo === "PF" && !data.cpf) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["cpf"],
message: "CPF obrigatório para PF",
});
}
if (data.tipo === "PJ" && !data.cnpj) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["cnpj"],
message: "CNPJ obrigatório para PJ",
});
}
});
A UI esconde o campo (watch) e o schema obriga a
existir (superRefine). Os dois juntos: o user não
vê o campo, e se tentar submeter via devtools,
o server também recusa.
useController pra inputs controlados por libs de
UI. MUI, Chakra, Ant Design, shadcn, react-select -
todas essas libs controlam o input via value e
onChange por prop. RHF, por padrão, é uncontrolled.
Pra conectar:
import { useForm, Controller } from "react-hook-form";
import Select from "react-select";
function Cadastro() {
const { control, handleSubmit } = useForm<FormData>({
defaultValues: { cidade: null },
});
return (
<Controller
name="cidade"
control={control}
render={({ field, fieldState }) => (
<div>
<Select
{...field}
options={cidades}
value={field.value}
onChange={field.onChange}
/>
{fieldState.error && (
<span role="alert">{fieldState.error.message}</span>
)}
</div>
)}
/>
);
}
O Controller é a "ponte" entre RHF (uncontrolled)
e a lib de UI (controlled). field tem value,
onChange, onBlur, name, ref - é a mesma
"forma" que register devolve, só que via render
prop em vez de spread. fieldState tem error,
isTouched, invalid - o que precisaria pra
mostrar erro e validação.
Quando usar Controller vs register:
register(default) - input HTML nativo, shadcn (que é HTML + classes), Headless UI, qualquer componente que aceitaname/onChange/ref. Mais performático (uncontrolled).Controller- libs de UI controladas (MUI, Chakra, Ant Design),react-select,react-datepicker, qualquer componente que exigevalue/onChangevia prop. Custa um re-render a cada mudança do campo (controlled), mas é a única forma de integrar.
A regra prática: use register por padrão, recorra
a Controller quando a lib exige controlled. Em
2026, com a migração pra libs "headless" (Radix,
Headless UI, shadcn), a maioria dos componentes
funciona com register.
Aprofundamento 🟡
useFormContext pra forms compostos. Quando o
form é dividido em vários sub-componentes
(<DadosPessoais />, <Endereco />, <Pagamento />),
o useFormContext destrava o acesso a useForm()
sem prop drilling:
// Topo: FormProvider distribui o form
import { useForm, FormProvider } from "react-hook-form";
function Checkout() {
const methods = useForm<CheckoutData>({
resolver: zodResolver(checkoutSchema),
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<DadosPessoais />
<Endereco />
<Pagamento />
<button type="submit">Finalizar</button>
</form>
</FormProvider>
);
}
// Filho: useFormContext, sem prop drilling
import { useFormContext } from "react-hook-form";
function DadosPessoais() {
const { register, formState: { errors } } = useFormContext<CheckoutData>();
return (
<div>
<input {...register("nome")} />
{errors.nome && <span>{errors.nome.message}</span>}
</div>
);
}
O useFormContext lê do contexto criado pelo
FormProvider. Funciona em qualquer profundidade.
É o padrão pra forms com 3+ seções ou pra
multi-step (nó 5).
Field arrays com default values dinâmicos. Quando
o useFieldArray precisa de items baseados em
dados externos (API, props), use defaultValues no
useForm:
const telefonesIniciais = await fetch("/api/telefones").then((r) => r.json());
useForm<FormData>({
defaultValues: { telefones: telefonesIniciais },
});
const { fields, append } = useFieldArray({ control, name: "telefones" });
// fields reflete os telefones iniciais
Validação por item do array (Zod). A validação aninhada do Zod funciona naturalmente:
const schema = z.object({
telefones: z
.array(
z.object({
numero: z.string().min(10, "Telefone inválido"),
tipo: z.enum(["celular", "fixo", "trabalho"]),
})
)
.min(1, "Adicione pelo menos 1 telefone")
.max(5, "Máximo 5 telefones"),
});
Os erros vêm como errors.telefones?.[0]?.numero?.message
(objeto aninhado). Pra UI, mapeia:
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`telefones.${index}.numero` as const)} />
{errors.telefones?.[index]?.numero && (
<span role="alert">{errors.telefones[index].numero.message}</span>
)}
</div>
))}
useWatch pra observar sem re-renderizar o pai.
O watch no topo do useForm re-renderiza o
componente que chamou useForm. Pra observar
valor em filho sem re-renderizar o pai:
import { useWatch } from "react-hook-form";
function Preview() {
const nome = useWatch({ control, name: "nome" });
return <p>Olá, {nome}!</p>;
}
Útil em formulários com preview em tempo real
(nome do produto enquanto digita, cálculo de total
enquanto muda quantidade). O pai do form não
re-renderiza - só o Preview.
useFormState pra ler estado em filhos. Similar
ao useFormContext, mas só pra formState. Útil
quando o pai tem o form inteiro e o filho só precisa
de errors:
import { useFormState } from "react-hook-form";
function Erros() {
const { errors } = useFormState({ name: ["nome", "email"] });
// Re-renderiza só quando errors de nome ou email mudam
return <pre>{JSON.stringify(errors, null, 2)}</pre>;
}
Padrão de otimização de performance em forms grandes: dividir a UI em componentes que observam só o que precisam.
Pra quem quer ir além 🔴
Drag-and-drop em field arrays com dnd-kit. A
biblioteca @dnd-kit/core integra naturalmente
com useFieldArray:
import { DndContext, closestCenter } from "@dnd-kit/core";
import { SortableContext, useSortable } from "@dnd-kit/sortable";
function ListaOrdenavel({ fields, onMove }: Props) {
return (
<DndContext onDragEnd={(e) => onMove(e.oldIndex, e.newIndex)}>
<SortableContext items={fields.map((f) => f.id)}>
{fields.map((field, i) => (
<Item key={field.id} field={field} index={i} />
))}
</SortableContext>
</DndContext>
);
}
Combinado com useFieldArray.move, dá pra
construir listas reordenáveis com drag handle,
acessibilidade por teclado, e sem perder o estado
do form. Aprofundamento pertence a uma trilha de
"interações avançadas" (a definir).
Schema dinâmico baseado em feature flag. Em sistemas com feature flags, o schema pode mudar em runtime:
const baseSchema = z.object({ nome: z.string() });
const schema = featureFlagHabilitada
? baseSchema.extend({ campoExtra: z.string() })
: baseSchema;
Funciona, mas a inferência de tipo muda. Cuidado
com useForm<z.infer<typeof schema>> quando o
schema pode ser um ou outro. Solução comum: schema
fixo com campos opcionais, feature flag só controla
a UI (mostrar ou esconder).
Server-side field array validation. Quando o backend precisa validar uma lista (ex: "no máximo 3 dependentes"), o mesmo schema Zod roda no server (nó 6). O RHF valida no client, o Zod valida no server - mesma fonte.
Performance: useFieldArray em forms com 100+
items. Em forms muito grandes (ex: importar 100
linhas de uma planilha), o useFieldArray pode
virar gargalo. Soluções:
useVirtualizerdoreact-windowoutanstack-virtualpra renderizar só a janela visível.- Dividir em chunks (submeter em batches).
- Submeter um item por vez via mutation.
É edge case de app de dados, não de form convencional.
Leitura recomendada:
- React Hook Form - useFieldArray - a referência.
- React Hook Form - useController - a ponte pra libs de UI controladas.
- TkDodo - Field Arrays - padrões e armadilhas.
Dica: o erro mais comum em field arrays é usar
key={index}em vez dekey={field.id}. Isso faz o React remontar os inputs a cada reordenação ou remoção, perdendo foco e estado de erro. Use semprekey={field.id}- o RHF dá um id estável por design.
No próximo nó, vamos compor tudo isso num multi-step
form (wizard): navegação entre steps, validação
por step, persistência local, e como o
FormProvider + useFormContext simplificam a
composição.
// Quiz
Por que `useFieldArray` exige `key={field.id}` em vez de `key={index}`?