Forms
Form components and validation
Forms use React Hook Form with Zod validation for type-safe, performant form handling.
Basic Form Structure
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
name: z.string().min(2),
});
type FormData = z.infer<typeof schema>;
function MyForm() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
email: "",
name: "",
},
});
const onSubmit = async (data: FormData) => {
// Handle form submission
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SubmitButton i18nButtonKey="save" />
</form>
</Form>
);
}FormField Pattern
Use the FormField component to integrate inputs with react-hook-form:
<FormField
control={form.control}
name="fieldName"
render={({ field }) => (
<FormItem>
<FormLabel>Label</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Optional helper text</FormDescription>
<FormMessage />
</FormItem>
)}
/>FormField Props
| Prop | Description |
|---|---|
control | Form control from form.control |
name | Field name (must match schema) |
render | Render function with field and error |
Submit Button
Use the SubmitButton component for automatic integration with form state:
import { SubmitButton } from "@/forms/submit-button";
<SubmitButton i18nButtonKey="save" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
i18nButtonKey | string | — | Translation key for button text |
skipDirtyCheck | boolean | false | Allow submit even when form is pristine |
By default, SubmitButton is disabled when the form is pristine (no changes). Use skipDirtyCheck={true} for create forms or when re-submitting should be allowed.
Form Validation
Validation errors are automatically translated using Zod error messages.
Custom Error Messages
const schema = z.object({
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"],
});File Upload
Use FileUpload for file inputs with drag-and-drop:
import { FileUpload } from "@/forms/file-upload";
<FileUpload
accept="image/*"
maxSize={5 * 1024 * 1024} // 5MB
onUpload={handleUpload}
/>Specialized Pickers
EnumPicker
Select from predefined enum values with custom rendering:
import EnumPicker from "@/forms/enum-picker";
<EnumPicker
items={MyStatus}
renderItem={(item) => ({
title: t(`status.${item}`),
description: t(`status.${item}Description`),
color: statusColors[item],
})}
value={form.watch("status")}
onChange={(val) => form.setValue("status", val)}
canClear
name="status"
/>NumberPicker
Number input with increment/decrement buttons:
import { NumberPicker } from "@/forms/number-picker";
<NumberPicker
value={quantity}
onChange={setQuantity}
min={1}
max={100}
step={1}
name="quantity"
/>AsyncPicker
Async data picker with search and tRPC integration:
import AsyncPicker from "@/forms/async-picker";
<AsyncPicker<User>
onSearch={async (query) => {
const result = await utils.users.search.fetch({ query });
return result.payload ?? [];
}}
onFetchSelected={async (ids) => {
const result = await utils.users.getByIds.fetch({ ids });
return result.payload ?? [];
}}
getItemId={(user) => user.id}
renderItem={(user) => (
<div className="flex items-center gap-2">
<Avatar src={user.image} size="sm" />
<span>{user.name}</span>
</div>
)}
value={selectedUserId}
onChange={(id, user) => setSelectedUserId(id)}
allowClear
/>MultiSelector
Multi-select with tag-style display:
import {
MultiSelector,
MultiSelectorTrigger,
MultiSelectorInput,
MultiSelectorContent,
MultiSelectorList,
MultiSelectorItem,
} from "@/forms/multi-select";
<MultiSelector
values={tags}
onValuesChange={setTags}
allowInsert
>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Add tags..." />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
{suggestedTags.map((tag) => (
<MultiSelectorItem key={tag} value={tag}>
{tag}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>Best Practices
Define validation with Zod for type-safety and automatic error messages
Ensures proper integration with react-hook-form
Override default Zod messages when needed for better UX
Automatically handles loading and disabled states