Forms
Forms quase sempre travam em dois problemas que não têm nada a ver um com o outro: como os campos se arrumam na tela e como os valores são validados. O SDK separa esses dois eixos de propósito — você pode adotar um sem o outro.
- Layout —
Form/FormSection/FormRow/FormActionscuidam de como os campos se arranjam na tela. - Validação —
validateForm/zodResolver/useZodFormcuidam de validar valores com zod.
Use os dois juntos ou separados — o Form component não se acopla a nenhuma form library.
Por que zod como fonte de verdade?
Em vez de declarar o tipo do form e depois escrever regras de validação que
podem divergir dele, você escreve um schema zod e o tipo TypeScript é
inferido com z.infer. Schema e tipo nunca saem de sincronia, e a mesma
regra vale no client (validateForm/zodResolver) e no servidor.
Layout — Form + subcomponentes
Substitui o padrão <form><Stack> boilerplate quando você quer um wrapper opinionado por layout.
import { Form, FormSection, FormRow, FormActions, Input, Select, Button } from "tempest-react-sdk";
De onde vem o form dos exemplos
Os trechos desta seção são sobre layout, então mostram só o JSX. O form
que aparece neles (form.register, form.handleSubmit, form.formState) é o
retorno do react-hook-form, criado uma vez no topo do componente:
const form = useZodForm(schema, { defaultValues: { name: "", email: "" } });
O primeiro exemplo abaixo — Stacked — está completo, com schema,
imports e componente. Veja useZodForm para o hook
e Exemplo completo para um
form ponta-a-ponta. Se você já usa useForm direto, const form = useForm({
resolver: zodResolver(schema) }) serve igual — o Form não se acopla a
nenhuma form library.
Variantes de layout
layout |
Comportamento |
|---|---|
"stack" |
(default) — flex coluna, gap = gap (default 4 → 16px). |
"inline" |
Flex row com flex-wrap: wrap + align-items: flex-end. Filter bars, login curto, etc. |
"grid" |
display: grid com grid-template-columns: repeat(columns, minmax(0, 1fr)). |
columns aceita number (repeat(N, minmax(0, 1fr))) ou string ("2fr 1fr", "min-content auto").
gap aceita number (escala 4px: 2 → 8px, 4 → 16px) ou string (qualquer CSS length: "1.5rem", "20px").
Stacked
O default: uma coluna, um campo por linha. Este exemplo está completo — copie, cole e rode.
import { Form, FormActions, Input, Button, useZodForm } from "tempest-react-sdk";
import { z } from "zod";
const signupSchema = z.object({
name: z.string().min(2, "Informe seu nome"),
email: z.string().email("Email inválido"),
password: z.string().min(8, "Mínimo de 8 caracteres"),
});
type SignupValues = z.infer<typeof signupSchema>;
export function SignupForm() {
const form = useZodForm(signupSchema, {
defaultValues: { name: "", email: "", password: "" },
});
const onSubmit = async (values: SignupValues) => {
await api.post("/signup", values);
};
return (
<Form layout="stack" gap={4} onSubmit={form.handleSubmit(onSubmit)}>
<Input label="Nome" {...form.register("name")} error={form.formState.errors.name?.message} />
<Input
label="Email"
type="email"
{...form.register("email")}
error={form.formState.errors.email?.message}
/>
<Input
label="Senha"
type="password"
{...form.register("password")}
error={form.formState.errors.password?.message}
/>
<FormActions align="end">
<Button type="submit" loading={form.formState.isSubmitting}>
Criar conta
</Button>
</FormActions>
</Form>
);
}
Os exemplos seguintes mostram só o JSX do Form — o form e o onSubmit vêm do
mesmo lugar que aqui.
Grid
<Form layout="grid" columns={2} gap={4} onSubmit={form.handleSubmit(onSubmit)}>
<Input label="Nome" {...form.register("name")} />
<Input label="Sobrenome" {...form.register("last_name")} />
<Input label="Email" type="email" {...form.register("email")} />
<Input label="Telefone" {...form.register("phone")} />
<FormActions align="end" style={{ gridColumn: "1 / -1" }}>
<Button type="submit">Salvar</Button>
</FormActions>
</Form>
Span de linha inteira
Use gridColumn: "1 / -1" para um campo (ou o FormActions) ocupar a linha
inteira do grid, independente de quantas colunas existem.
Inline
Filter bar não precisa de form library nenhuma — onSubmit aqui é o handler
nativo do <form>, que o Form repassa direto.
const statusOptions = [
{ value: "active", label: "Ativo" },
{ value: "archived", label: "Arquivado" },
];
function handleFilter(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setFilters({ q: String(data.get("q") ?? ""), status: String(data.get("status") ?? "") });
}
<Form layout="inline" gap={2} onSubmit={handleFilter}>
<Input name="q" label="Buscar" placeholder="nome…" />
<Select name="status" label="Status" options={statusOptions} />
<Button type="submit">Filtrar</Button>
</Form>;
Filter bars típicos: align-items: flex-end faz os botões ficarem alinhados com o baseline dos inputs.
FormSection — subgrupos com layout independente
<Form layout="stack" gap={5}>
<Input label="Email" {...form.register("email")} />
<FormSection title="Endereço" description="Usado para entrega" layout="grid" columns={3} gap={3}>
<Input label="CEP" {...form.register("cep")} />
<Input label="Cidade" {...form.register("city")} />
<Input label="UF" {...form.register("state")} />
<Input label="Rua" style={{ gridColumn: "1 / -1" }} {...form.register("street")} />
</FormSection>
<FormActions align="end">
<Button type="submit">Salvar</Button>
</FormActions>
</Form>
FormSection renderiza um <section> com <header> (quando title ou description presente) + body. O body tem o seu próprio layout / columns / gap.
FormRow — side-by-side dentro de stack
<Form layout="stack" gap={4}>
<Input label="Número do cartão" />
<FormRow gap={3}>
<Input label="Validade" placeholder="MM/AA" />
<Input label="CVV" />
</FormRow>
</Form>
FormRow sempre é horizontal com flex-wrap: wrap. Children dividem largura igualmente (flex: 1 1 0).
FormActions — footer buttons
<FormActions align="between" gap={2}>
<Button variant="ghost" type="button" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
Salvar
</Button>
</FormActions>
align: "start" / "center" / "end" (default) / "between".
Validação — zod
Três níveis de integração com zod, do mais leve ao mais opinativo. Schema é a fonte de verdade — o tipo é inferido.
1. validateForm — agnóstico
Não requer react-hook-form. Útil pra forms controlados manuais ou validação em batch.
import { validateForm } from "tempest-react-sdk";
const result = validateForm(schema, values);
if (!result.success) {
setErrors(result.errors); // Record<path, message>
return;
}
await save(result.data);
Path em dot-notation ("address.city", "items.0.name"). Erros de root: _root.
Primeiro erro por campo vence
validateForm mantém só a primeira issue de cada path no map de erros —
UIs de form quase sempre mostram uma mensagem por campo. Se você precisa de
todas as mensagens (ex.: checklist de regras de senha), use
schema.safeParse(values).error.issues direto.
2. zodResolver — react-hook-form
Substitui @hookform/resolvers/zod (sem dependência extra).
import { useForm } from "react-hook-form";
import { zodResolver } from "tempest-react-sdk";
const form = useForm({ resolver: zodResolver(loginSchema) });
3. useZodForm — tudo-em-um
const form = useZodForm(loginSchema, { defaultValues: { email: "", password: "" } });
<Form layout="stack" onSubmit={form.handleSubmit(login)}>
<Input label="Email" {...form.register("email")} error={form.formState.errors.email?.message} />
<Input
label="Senha"
type="password"
{...form.register("password")}
error={form.formState.errors.password?.message}
/>
<FormActions>
<Button type="submit" loading={form.formState.isSubmitting}>
Entrar
</Button>
</FormActions>
</Form>;
Exemplo completo — schema → provider → fields → submit
Os três trechos acima são fatias da mesma história. Aqui está um form ponta-a-ponta
de cadastro: um único schema dirige tipos, validação e o <FormField> injeta o
estado do RHF em cada controle sem <Controller> repetido.
import {
Form,
FormField,
FormProvider,
FormActions,
Button,
Input,
useZodForm,
type SubmitHandler,
} from "tempest-react-sdk";
import { z } from "zod";
// 1. Schema = fonte de verdade. O tipo SignupValues é inferido dele.
const signupSchema = z
.object({
name: z.string().min(2, "Informe seu nome"),
email: z.string().email("Email inválido"),
password: z.string().min(8, "Mínimo de 8 caracteres"),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "As senhas não conferem",
path: ["confirm"], // erro atribuído ao campo "confirm"
});
type SignupValues = z.infer<typeof signupSchema>;
export function SignupForm() {
// 2. useZodForm fia o resolver zod + infere SignupValues automaticamente.
const form = useZodForm(signupSchema, {
defaultValues: { name: "", email: "", password: "", confirm: "" },
});
// 4. handleSubmit só dispara o submit quando o schema passa.
const onSubmit: SubmitHandler<SignupValues> = async (values) => {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(values),
});
};
return (
// 3. FormProvider expõe o `control` na árvore; FormField o consome via contexto.
<FormProvider {...form}>
<Form layout="stack" gap={4} onSubmit={form.handleSubmit(onSubmit)}>
<FormField name="name" label="Nome" required>
<Input />
</FormField>
<FormField name="email" label="Email" required>
<Input type="email" />
</FormField>
<FormField name="password" label="Senha" required>
<Input type="password" />
</FormField>
<FormField name="confirm" label="Confirmar senha" required>
<Input type="password" />
</FormField>
<FormActions align="end">
<Button type="submit" loading={form.formState.isSubmitting}>
Criar conta
</Button>
</FormActions>
</Form>
</FormProvider>
);
}
Como as peças se encaixam:
useZodForm(schema)retorna o objetoUseFormReturndo react-hook-form, já com o resolver zod plugado. Você ganharegister,handleSubmit,formState,controletc. tipados em cima deSignupValues.<FormProvider {...form}>publica esse retorno no contexto do RHF. É o que permite o<FormField>achar ocontrolsozinho.<FormField name="..." label="...">envolve um<Controller>por baixo e usacloneElementpra injetarvalue/onChange/onBlur/ref/error/aria-invalidno controle filho. Você passa um<Input />"burro" e o FormField conecta tudo.form.handleSubmit(onSubmit)roda o schema antes;onSubmitsó recebevaluesjá validados e tipados.
FormField elimina o <Controller> repetido
Sem ele, cada campo controlado vira um <Controller name=... render={({ field, fieldState }) => ...} />
de 5 linhas. O <FormField> faz esse render-prop uma vez e repassa error +
aria-invalid pro controle automaticamente.
FormField precisa de control ou Provider
<FormField> busca o control no contexto do <FormProvider>. Se você não
quer um provider, passe control={form.control} explicitamente em cada
<FormField>. Sem nenhum dos dois ele lança
"FormField requires either a control prop or a <FormProvider> in the tree.".
O controle filho precisa aceitar as props injetadas
O <FormField> espera que o filho aceite value / onChange / onBlur /
ref / error / label. Os componentes do SDK (Input, Select, e os
inputs com máscara BR) já aceitam — um <input> cru do DOM
não entende error/label e vai logar warning de prop desconhecida.
Erros cross-field
Validações que dependem de dois campos (senha × confirmação) vão num
.refine() no schema com path: ["campo"] — assim o erro aparece atribuído ao
campo certo no formState.errors.
O react-hook-form sai pelo mesmo barrel
O SDK re-exporta os hooks do react-hook-form que você usaria junto com
useZodForm, então um arquivo de formulário tem um import só:
import {
FormProvider,
useFieldArray,
useFormContext,
useFormState,
useWatch,
useZodForm,
} from "tempest-react-sdk";
| Hook | Para que serve |
|---|---|
useFieldArray |
Lista dinâmica de campos (itens de um pedido, telefones) com append/remove. |
useFormContext |
Alcança o form de dentro de um componente-filho, sem passar control por prop. |
useWatch |
Observa um campo re-renderizando só quem observa, não o formulário inteiro. |
useFormState |
Lê errors/isDirty/isSubmitting com a mesma granularidade de subscrição. |
São os hooks do react-hook-form, sem casca: a documentação deles vale
literalmente, e importar direto de react-hook-form funciona igual. O
re-export existe para o app não precisar declarar a dependência que o SDK já
traz.
useWatch e useFormState existem por performance
form.watch("campo") e form.formState.errors re-renderizam o componente
que segura o form — num formulário grande, isso é a tela inteira a cada
tecla. Os hooks assinam só o pedaço observado, no componente que observa.
\"FormField found no form to bind to\" com um <FormProvider> visível acima
Quando isso acontece com o provider montado logo acima, a causa não é o que a
frase sugere: o app e o SDK estão resolvendo duas cópias do
react-hook-form. O <FormProvider> publica no contexto de uma cópia e o
useFormContext de dentro do <FormField> lê o da outra, que está vazio.
É o mesmo mecanismo do useNavigate() may be used only in the context of a
<Router>, e passa despercebido pela mesma razão: ninguém vai olhar
node_modules enquanto está olhando pro provider na tela. Por isso a mensagem
de erro do SDK nomeia as duas causas desde a v0.52.1.
Conserto: npm dedupe, ou npx tempest doctor para listar as duplicadas.
Padrão de schema
Mantenha schemas em src/schemas/<dominio>.ts, exporte o z.infer em src/types/<dominio>.ts via declare global quando quiser tipos globais. Convenção herdada do alofans-frontend.
Resumo
- O SDK separa layout (
Form/FormSection/FormRow/FormActions) de validação (zod) — adote um sem o outro. - A validação tem três degraus:
validateForm(agnóstico),zodResolver(RHF),useZodForm(tudo-em-um). - Um único schema zod dirige tipo (
z.infer), validação e mensagens de erro. <FormProvider>+<FormField>removem o<Controller>repetido e propagamerror+aria-invalidpros controles.handleSubmitsó chama seuonSubmitcom valores já validados.
Veja também
- Forms BR —
CPFInput/CNPJInput/CEPInput/MoneyInput/useViaCEP/ algoritmos de validação BR - HTTP —
parseResponseusa o mesmo zod - Componentes —
Input/Select/Textareaque vivem dentro do Form - Hooks —
useAsyncpara o estado do submit quando você não usa RHF