Conventions for building forms in this project. Load when creating, editing, or refactoring any form, input, or validation logic. Covers schema setup, library choice, field errors, and submission handling.
| Situation | Library |
|---|---|
| Standard forms | react-hook-form + valibot |
| Complex/dynamic forms (dependent fields, multi-step) | @tanstack/react-form + valibot |
Default to react-hook-form unless the form has dynamic field dependencies or significant orchestration needs.
Default to valibot over zod — lighter bundle, same expressiveness.
import { object, string, email, minLength, pipe, InferOutput } from "valibot";
const loginSchema = object({
email: pipe(string(), email()),
password: pipe(string(), minLength(8)),
});
type LoginForm = InferOutput<typeof loginSchema>;
import { useForm } from "react-hook-form";
import { valibotResolver } from "@hookform/resolvers/valibot";
const form = useForm<LoginForm>({
resolver: valibotResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
defaultValues — prevents uncontrolled/controlled warnings<Form> wrapper + <FormField>, <FormItem>, <FormMessage> for consistent layoutimport { useForm } from "@tanstack/react-form";
import { valibotValidator } from "@tanstack/valibot-form-adapter";
const form = useForm({
validatorAdapter: valibotValidator(),
defaultValues: { email: "", password: "" },
});
| Context | Display |
|---|---|
| Field validation error | Inline, below the field via <FormMessage> |
| Submission error (server/action) | Toast — not a field error |
| Global form error (e.g. rate limit) | Toast |
Never show field errors as toasts. Never show action errors inline.
const onSubmit = form.handleSubmit(async (values) => {
try {
await myAction(values);
toast.success("Done");
} catch (err) {
toast.error("Something went wrong");
}
});
form.formState.isSubmitting)defaultValues setvalibot used unless project already uses zod