Fundamentos de Formulários e Validação
13 exemplos para você começar com Formulários e Validação -- 8 básicos e 5 intermediários.
Busque em todas as páginas da documentação
13 exemplos para você começar com Formulários e Validação -- 8 básicos e 5 intermediários.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
A maioria dos exemplos assume um projeto Next.js 15+ com App Router e TypeScript. Os formulários não triviais usam Zod e react-hook-form:
npm install zod react-hook-form @hookform/resolversConvenções usadas ao longo do documento:
"use client"). Formulários nativos <form action={serverAction}> podem permanecer no lado do servidor.value no estado do React; inputs não controlados são lidos via FormData ou ref.Escolhendo uma abordagem? Veja o Checklist de Decisão para escolher o padrão correto para seu formulário antes de escrever o código.
O formulário React 19 mais simples -- sem estado, sem bibliotecas, apenas HTML e FormData.
"use client";
export default function ContactForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
console.log({
name: data.get("name"),
email: data.get("email"),
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Enviar</button>
</form>
);
}new FormData(form) lê cada input nomeado em uma única chamada; você nunca precisa de refs individuais.required, type="email", minLength e similares usam a validação HTML5 nativa do navegador.onSubmit por <form action={serverAction}> para obter melhoria progressiva gratuitamente.Relacionado: Controlado vs Não Controlado -- quando usar cada um | Padrões de Formulário Básico -- templates de login, cadastro, contato
Mantenha o valor do input no estado do React quando precisar validar, transformar ou renderizar condicionalmente com base nele.
"use client";
import { useState } from "react";
export default function SearchBox() {
const [query, setQuery] = useState("");
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Pesquisar..."
/>
{query.length > 0 && <p>Digitando: {query}</p>}
</div>
);
}value é estado, onChange atualiza estado -- React é o dono do input.value={state} -- nunca defaultValue -- ou você receberá um aviso "controlado-para-não-controlado".Relacionado: Controlado vs Não Controlado -- comparação completa e quando mudar | React Hook Form -- formulários não controlados que escalam além de poucos campos
Defina um schema em tempo de execução e parseie os dados recebidos -- lança erro ou retorna um objeto tipado.
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email("Email inválido"),
age: z.number().min(18, "Deve ter 18 anos ou mais"),
});
const result = UserSchema.safeParse({ email: "ada@example.com", age: 30 });
if (!result.success) {
console.log(result.error.flatten().fieldErrors);
} else {
// result.data é totalmente tipado como { email: string; age: number }
console.log(result.data);
}safeParse retorna { success, data | error } -- use-o quando quiser tratar erros sem try/catch..email(), .min(), .max()) cada um adiciona uma regra e anexa a mensagem de erro.parse em vez disso quando tiver certeza de que os dados são válidos e quiser que ele lance um erro na falha.Relacionado: Fundamentos Zod -- schemas, erros, safeParse vs parse | Tipos Zod -- cada tipo primitivo e composto | Transformações Zod -- transform, refine, preprocess
Derive o tipo estático diretamente do schema para que haja apenas uma coisa para manter sincronizada.
import { z } from "zod";
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
price: z.number().positive(),
tags: z.array(z.string()).default([]),
});
type Product = z.infer<typeof ProductSchema>;
// { id: string; name: string; price: number; tags: string[] }z.infer<typeof Schema> lê o tipo de saída do schema -- renomeie um campo uma vez e tanto o tipo quanto o validador se atualizam juntos.z.input fornece o tipo de entrada (antes das transformações); z.output fornece o tipo de saída (após as transformações).Relacionado: Zod Infer --
z.infervsz.inputvsz.output, armadilhas de transformação | Tipando Respostas de API -- usando schemas nas fronteiras de fetch
Escale além de poucos campos com formulários com controle não controlado internamente e registro por campo.
"use client";
import { useForm } from "react-hook-form";
interface FormValues {
email: string;
password: string;
}
export default function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>();
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email", { required: "Email obrigatório" })} />
{errors.email && <span>{errors.email.message}</span>}
<input
type="password"
{...register("password", { minLength: { value: 8, message: "Mínimo 8 caracteres" } })}
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Entrar</button>
</form>
);
}register(name, rules) conecta um input ao formulário, rastreia seu valor e executa a validação -- sem estado por campo.handleSubmit executa a validação primeiro; seu callback só é acionado com dados limpos e tipados.Controller do RHF em vez de register.Relacionado: React Hook Form -- register, Controller, watch, reset | Padrões de Formulário Básico -- receitas de login, cadastro, contato
Use um schema Zod como a única fonte de verdade para validação e tipos.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const SignupSchema = z.object({
name: z.string().min(1, "Nome obrigatório"),
email: z.string().email("Email inválido"),
age: z.coerce.number().min(18, "Deve ter 18+"),
});
type Signup = z.infer<typeof SignupSchema>;
export default function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<Signup>({
resolver: zodResolver(SignupSchema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register("age")} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Cadastrar</button>
</form>
);
}zodResolver(schema) conecta Zod ao RHF -- mensagens de erro aparecem em errors.<field>.message automaticamente.z.coerce.number() transforma a string do <input> em um número, economizando um passo manual de parse.Relacionado: RHF + Zod -- análise aprofundada, padrões, campos dinâmicos | shadcn Form -- mesma pilha com componentes shadcn acessíveis
Valide no servidor, retorne erros para o formulário e rastreie o estado pendente com useActionState.
// app/contact/actions.ts
"use server";
import { z } from "zod";
const ContactSchema = z.object({
email: z.string().email(),
message: z.string().min(10),
});
type State = { ok: boolean; errors?: Record<string, string[]>; };
export async function submitContact(_prev: State | null, formData: FormData): Promise<State> {
const parsed = ContactSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
await fetch("https://api.example.com/contacts", {
method: "POST",
body: JSON.stringify(parsed.data),
});
return { ok: true };
}// app/contact/form.tsx
"use client";
import { useActionState } from "react";
import { submitContact } from "./actions";
export default function ContactForm() {
const [state, action, isPending] = useActionState(submitContact, null);
return (
<form action={action}>
<input name="email" />
{state?.errors?.email && <p>{state.errors.email[0]}</p>}
<textarea name="message" />
{state?.errors?.message && <p>{state.errors.message[0]}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Enviando..." : "Enviar"}
</button>
</form>
);
}Object.fromEntries(formData) transforma FormData em um objeto simples que o Zod pode parsear.useActionState retorna [state, action, isPending] -- conecte action diretamente ao formulário.state -- use-o para renderizar erros por campo.Relacionado: Formulários com Server Actions -- padrão completo de ponta a ponta com redirects e revalidação | useActionState -- API do hook | Server Actions -- o primitivo subjacente
Mostre erros abaixo de cada campo de forma que os leitores de tela anunciem.
"use client";
import { useState } from "react";
export default function EmailField() {
const [email, setEmail] = useState("");
const [touched, setTouched] = useState(false);
const error = touched && !email.includes("@") ? "Por favor, insira um email válido" : null;
const errorId = "email-error";
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<p id={errorId} role="alert">
{error}
</p>
)}
</div>
);
}aria-invalid anuncia que o valor atual do input é rejeitado.aria-describedby vincula o input à sua mensagem de erro para que os leitores de tela os leiam juntos.role="alert" no erro faz com que o anúncio dispare quando o erro aparece, não antes.onBlur ou envio para que os usuários não sejam incomodados enquanto digitam.Relacionado: Exibição de Erros de Formulário -- padrões inline, de resumo e toast | Acessibilidade de Formulários -- ARIA, foco e anúncios em profundidade
Use os primitivos Form do shadcn para conectar react-hook-form + Zod a UI acessível e estilizada em poucas linhas.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const Schema = z.object({
username: z.string().min(2).max(50),
});
export default function ProfileForm() {
const form = useForm<z.infer<typeof Schema>>({
resolver: zodResolver(Schema),
defaultValues: { username: "" },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => console.log(v))}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Nome de usuário</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Salvar</Button>
</form>
</Form>
);
}Form do shadcn é um wrapper fino que passa o contexto do RHF através de seus subcomponentes -- toda a infraestrutura ARIA é automática.FormMessage renderiza a mensagem de erro do campo atual sem qualquer lookup manual de errors.<field>.message.npx shadcn@latest add form input button gera os componentes.Relacionado: Formulário shadcn -- receita completa de formulário shadcn | shadcn Form (componente) -- os primitivos de UI | RHF + Zod -- a pilha por baixo
Mostre o novo item instantaneamente enquanto a action do servidor é executada; reverta automaticamente em caso de falha.
"use client";
import { useOptimistic, useRef } from "react";
interface Todo { id: string; title: string; }
export default function TodoList({
todos,
addTodo,
}: {
todos: Todo[];
addTodo: (title: string) => Promise<void>;
}) {
const formRef = useRef<HTMLFormElement>(null);
const [optimistic, addOptimistic] = useOptimistic(
todos,
(state, next: Todo) => [...state, next]
);
const action = async (formData: FormData) => {
const title = formData.get("title") as string;
addOptimistic({ id: `temp-${Date.now()}`, title });
formRef.current?.reset();
await addTodo(title);
};
return (
<>
<ul>
{optimistic.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
<form ref={formRef} action={action}>
<input name="title" required />
<button type="submit">Adicionar</button>
</form>
</>
);
}useOptimistic(state, reducer) retorna um estado projetado; as mudanças desaparecem automaticamente se a action lançar um erro ou se o estado do servidor as substituir.temp-<timestamp>) até que o servidor retorne o ID real.form.reset() limpa o input imediatamente -- o usuário pode começar a digitar a próxima entrada enquanto o servidor processa.Relacionado: Padrões de Formulário Otimista -- padrões de rollback, UX de erro | useOptimistic (hooks) -- API do hook | useOptimistic (React 19) -- o primitivo do React 19
Aceite arquivos por clique ou arrastar, pré-visualize-os e valide tamanho/tipo com Zod.
"use client";
import { useState } from "react";
import { z } from "zod";
const FileSchema = z
.instanceof(File)
.refine((f) => f.size < 2 * 1024 * 1024, "Máximo 2MB")
.refine((f) => f.type.startsWith("image/"), "Apenas imagens");
export default function ImageUploader() {
const [preview, setPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFile = (file: File) => {
const result = FileSchema.safeParse(file);
if (!result.success) {
setError(result.error.issues[0].message);
return;
}
setError(null);
setPreview(URL.createObjectURL(file));
};
return (
<label
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}}
style={{ display: "block", border: "2px dashed #999", padding: "2rem" }}
>
Arraste uma imagem ou clique para procurar
<input
type="file"
hidden
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
{preview && <img src={preview} alt="preview" style={{ maxWidth: 200 }} />}
{error && <p role="alert">{error}</p>}
</label>
);
}z.instanceof(File).refine(...) permite aplicar a API fluente do Zod a objetos File do navegador.URL.createObjectURL(file) fornece uma URL de pré-visualização local -- chame URL.revokeObjectURL ao desmontar para evitar vazamentos em sessões longas.onDragOver deve chamar e.preventDefault() ou o navegador rejeitará o drop completamente.Relacionado: Padrões de Upload de Arquivo -- drag-drop, múltiplos arquivos, progresso, S3 | Eventos Drag & Drop -- a API de eventos subjacente
Gerencie o estado do assistente -- campos, índice da etapa, validação -- com ações de reducer explícitas.
"use client";
import { useReducer } from "react";
interface State {
step: number;
data: { name: string; email: string; plan: string };
}
type Action =
| { type: "set"; field: keyof State["data"]; value: string }
| { type: "next" }
| { type: "back" }
| { type: "reset" };
const initial: State = { step: 0, data: { name: "", email: "", plan: "" } };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "set":
return { ...state, data: { ...state.data, [action.field]: action.value } };
case "next":
return { ...state, step: state.step + 1 };
case "back":
return { ...state, step: Math.max(0, state.step - 1) };
case "reset":
return initial;
}
}
export default function Wizard() {
const [state, dispatch] = useReducer(reducer, initial);
return (
<div>
{state.step === 0 && (
<input
placeholder="Nome"
value={state.data.name}
onChange={(e) => dispatch({ type: "set", field: "name", value: e.target.value })}
/>
)}
{state.step === 1 && (
<input
placeholder="Email"
value={state.data.email}
onChange={(e) => dispatch({ type: "set", field: "email", value: e.target.value })}
/>
)}
{state.step === 2 && <p>Revisão: {JSON.stringify(state.data)}</p>}
<button onClick={() => dispatch({ type: "back" })} disabled={state.step === 0}>
Voltar
</button>
<button onClick={() => dispatch({ type: "next" })} disabled={state.step === 2}>
Próximo
</button>
</div>
);
}dispatch({ type: "next" }).Relacionado: Formulários Multi-Etapas com useReducer -- testes, guardas de validação, persistência | Padrões de Formulário Complexos -- assistentes, arrays de campos, campos condicionais | useReducer -- o hook subjacente
Junta tudo: schema Zod, RHF, erros acessíveis, handler de submit tipado.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const LoginSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(8, "Pelo menos 8 caracteres"),
});
type LoginValues = z.infer<typeof LoginSchema>;
export default function LoginForm() {
const {
register, handleSubmit, formState: { errors, isSubmitting },
} = useForm<LoginValues>({ resolver: zodResolver(LoginSchema) });
const onSubmit = async (values: LoginValues) => {
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(values),
});
if (!res.ok) alert("Falha no login");
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Email</label>
<input
id="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-err" : undefined}
{...register("email")}
/>
{errors.email && <p id="email-err" role="alert">{errors.email.message}</p>}
<label htmlFor="password">Senha</label>
<input
id="password"
type="password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "pw-err" : undefined}
{...register("password")}
/>
{errors.password && <p id="pw-err" role="alert">{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Entrando..." : "Entrar"}
</button>
</form>
);
}noValidate no <form> desabilita as mensagens HTML5 do navegador para que seus erros Zod/RHF sejam a única fonte de verdade.isSubmitting vem do formState do RHF -- use-o para desabilitar o botão de submit e prevenir envios duplicados.aria-describedby com um id correspondente no elemento de erro para que os leitores de tela os vinculem.fetch por uma Server Action e mude para useActionState quando quiser melhoria progressiva.Relacionado: Padrões de Formulário Básico -- mais templates de formulário prontos para uso | Acessibilidade de Formulários -- padrões ARIA em profundidade | Checklist de Decisão -- escolhendo a abordagem certa para um determinado formulário
Revisado por Chris St. John·Última atualização: 16 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥