원클릭으로
react-hook-form-zod-shadcn
Complete guide to building type-safe forms with react-hook-form v7, Zod v4, and shadcn/ui Form components
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Complete guide to building type-safe forms with react-hook-form v7, Zod v4, and shadcn/ui Form components
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Production-grade Expo and React Native best practices covering performance, navigation, state management, native APIs, platform-specific code, OTA updates, testing, and accessibility
Advanced React Native component patterns with TypeScript including compound components, headless components, HOCs, polymorphic pressables, animated patterns, and platform-adaptive components
Opinionated Expo + React Native + TypeScript project structure and screen-splitting guidance. Use this whenever the user asks how to organize a mobile codebase, where files should live, how to split large screens or components, how to structure components, hooks, services, store, constants, types, or navigation, or when an Expo app feels messy and needs a consistent architecture.
REST and GraphQL API design patterns for NestJS covering DTOs, versioning, pagination, filtering, sorting, caching, rate limiting, file uploads, and OpenAPI documentation. Use when building NestJS APIs, designing endpoints, implementing query patterns, or optimizing API performance.
Authentication and authorization deep-dive for NestJS covering JWT, sessions, refresh tokens, RBAC, permissions, guards, decorators, OAuth2, multi-tenancy, and security hardening. Use when implementing auth flows, role-based access control, permission systems, or securing NestJS APIs.
Deep Drizzle ORM patterns for NestJS applications covering schema design, migrations, relations, transactions, query builder, repositories, and testing. Use when integrating Drizzle with NestJS, designing database schemas, writing complex queries, or managing migrations.
| name | react-hook-form-zod-shadcn |
| description | Complete guide to building type-safe forms with react-hook-form v7, Zod v4, and shadcn/ui Form components |
| version | 1.0.0 |
| type | skill |
| tags | ["react","forms","zod","shadcn-ui","validation","typescript"] |
| category | Forms |
| author | agent-skills |
Build production-grade, type-safe forms using react-hook-form v7, Zod v4 schema validation, and shadcn/ui Form primitives. All examples use TypeScript.
pnpm add react-hook-form @hookform/resolvers zod
npx shadcn@latest add form input label select checkbox textarea switch radio-group
"use client";
import { useForm } from "react-hook-form";
import { z } from "zod/v4";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
// 1. Define schema
const contactSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Invalid email address"),
phone: z.string().optional(),
message: z.string().min(10, "Message must be at least 10 characters").max(500),
});
// 2. Infer TypeScript type from schema
type ContactFormValues = z.infer<typeof contactSchema>;
// 3. Build the form
export function ContactForm() {
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: "",
email: "",
phone: "",
message: "",
},
});
async function onSubmit(data: ContactFormValues) {
console.log("Valid form data:", data);
// data is fully typed: { name: string; email: string; phone?: string; message: string }
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="john@example.com" {...field} />
</FormControl>
<FormDescription>We will never share your email.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<textarea
className="flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Your message..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Sending..." : "Send Message"}
</Button>
</form>
</Form>
);
}
import { z } from "zod/v4";
// String validations
const userSchema = z.object({
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be at most 20 characters")
.regex(/^[a-z0-9_-]+$/, "Username can only contain lowercase letters, numbers, hyphens, and underscores"),
email: z.email("Invalid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[0-9]/, "Password must contain at least one number")
.regex(/[^a-zA-Z0-9]/, "Password must contain at least one special character"),
age: z.number().int().min(13, "Must be at least 13 years old").max(120),
website: z.url("Invalid URL").optional(),
});
// Enum and literal unions
const roleSchema = z.enum(["admin", "editor", "viewer"]);
// Discriminated unions
const notificationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("email"),
emailAddress: z.email(),
frequency: z.enum(["daily", "weekly", "monthly"]),
}),
z.object({
type: z.literal("sms"),
phoneNumber: z.string().min(10),
}),
z.object({
type: z.literal("push"),
deviceToken: z.string(),
}),
]);
// Refinements for cross-field validation
const passwordFormSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
// Transform values
const priceSchema = z.object({
amount: z
.string()
.transform((val) => parseFloat(val))
.pipe(z.number().positive("Amount must be positive")),
currency: z.enum(["USD", "EUR", "GBP"]).default("USD"),
});
// Conditional fields with superRefine
const registrationSchema = z
.object({
accountType: z.enum(["personal", "business"]),
companyName: z.string().optional(),
taxId: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.accountType === "business") {
if (!data.companyName || data.companyName.length < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Company name is required for business accounts",
path: ["companyName"],
});
}
if (!data.taxId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Tax ID is required for business accounts",
path: ["taxId"],
});
}
}
});
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="editor">Editor</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
import { Checkbox } from "@/components/ui/checkbox";
<FormField
control={form.control}
name="acceptTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Accept terms and conditions</FormLabel>
<FormDescription>
You agree to our Terms of Service and Privacy Policy.
</FormDescription>
</div>
<FormMessage />
</FormItem>
)}
/>
import { Switch } from "@/components/ui/switch";
<FormField
control={form.control}
name="notifications"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Email Notifications</FormLabel>
<FormDescription>Receive emails about account activity.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>URL Slug</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
rules={{
validate: {
noSpaces: (value: string) =>
!value.includes(" ") || "Slug cannot contain spaces",
noUppercase: (value: string) =>
value === value.toLowerCase() || "Slug must be lowercase",
},
}}
/>
const checkoutSchema = z
.object({
shippingMethod: z.enum(["standard", "express", "pickup"]),
address: z.string().optional(),
city: z.string().optional(),
zip: z.string().optional(),
pickupLocation: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.shippingMethod !== "pickup") {
if (!data.address) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Address is required for delivery",
path: ["address"],
});
}
if (!data.city) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "City is required for delivery",
path: ["city"],
});
}
if (!data.zip) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "ZIP code is required for delivery",
path: ["zip"],
});
}
} else {
if (!data.pickupLocation) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Please select a pickup location",
path: ["pickupLocation"],
});
}
}
});
const signupSchema = z.object({
username: z
.string()
.min(3)
.max(20)
.refine(
async (username) => {
const response = await fetch(`/api/check-username?q=${username}`);
const { available } = await response.json();
return available;
},
{ message: "Username is already taken" }
),
email: z.email(),
});
// Form with async validation and debounce
export function SignupForm() {
const form = useForm<z.infer<typeof signupSchema>>({
resolver: zodResolver(signupSchema),
defaultValues: { username: "", email: "" },
mode: "onBlur", // Validate on blur to avoid too many async calls
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<div className="relative">
<Input {...field} />
{form.formState.isValidating && (
<Spinner className="absolute right-3 top-2.5 h-4 w-4" />
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
Sign Up
</Button>
</form>
</Form>
);
}
import { useFieldArray, useForm } from "react-hook-form";
import { z } from "zod/v4";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
const invoiceSchema = z.object({
client: z.string().min(1, "Client name is required"),
items: z
.array(
z.object({
description: z.string().min(1, "Description is required"),
quantity: z.number().int().min(1, "Minimum quantity is 1"),
unitPrice: z.number().min(0.01, "Price must be positive"),
})
)
.min(1, "At least one line item is required"),
notes: z.string().optional(),
});
type InvoiceFormValues = z.infer<typeof invoiceSchema>;
export function InvoiceForm() {
const form = useForm<InvoiceFormValues>({
resolver: zodResolver(invoiceSchema),
defaultValues: {
client: "",
items: [{ description: "", quantity: 1, unitPrice: 0 }],
notes: "",
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "items",
});
const watchedItems = form.watch("items");
const total = watchedItems.reduce(
(sum, item) => sum + (item.quantity || 0) * (item.unitPrice || 0),
0
);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="client"
render={({ field }) => (
<FormItem>
<FormLabel>Client</FormLabel>
<FormControl>
<Input placeholder="Client name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Line Items</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ description: "", quantity: 1, unitPrice: 0 })}
>
<Plus className="mr-1 h-4 w-4" />
Add Item
</Button>
</div>
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-12 gap-3 items-start">
<div className="col-span-5">
<FormField
control={form.control}
name={`items.${index}.description`}
render={({ field }) => (
<FormItem>
{index === 0 && <FormLabel>Description</FormLabel>}
<FormControl>
<Input placeholder="Item description" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="col-span-2">
<FormField
control={form.control}
name={`items.${index}.quantity`}
render={({ field }) => (
<FormItem>
{index === 0 && <FormLabel>Qty</FormLabel>}
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="col-span-3">
<FormField
control={form.control}
name={`items.${index}.unitPrice`}
render={({ field }) => (
<FormItem>
{index === 0 && <FormLabel>Unit Price</FormLabel>}
<FormControl>
<Input
type="number"
step="0.01"
min={0}
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="col-span-2 flex items-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
disabled={fields.length === 1}
className={index === 0 ? "mt-8" : ""}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{form.formState.errors.items?.root && (
<p className="text-sm text-destructive">
{form.formState.errors.items.root.message}
</p>
)}
</div>
<div className="text-right text-lg font-semibold">
Total: ${total.toFixed(2)}
</div>
<Button type="submit" disabled={form.formState.isSubmitting}>
Create Invoice
</Button>
</form>
</Form>
);
}
import { z } from "zod/v4";
import { useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// Step schemas
const personalInfoSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.email("Invalid email"),
});
const addressSchema = z.object({
street: z.string().min(1, "Street is required"),
city: z.string().min(1, "City is required"),
state: z.string().min(1, "State is required"),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, "Invalid ZIP code"),
});
const preferencesSchema = z.object({
newsletter: z.boolean().default(false),
theme: z.enum(["light", "dark", "system"]).default("system"),
language: z.string().default("en"),
});
// Composed full schema
const fullSchema = personalInfoSchema.merge(addressSchema).merge(preferencesSchema);
type FullFormValues = z.infer<typeof fullSchema>;
// Step schemas array for per-step validation
const stepSchemas = [personalInfoSchema, addressSchema, preferencesSchema] as const;
const STEPS = ["Personal Info", "Address", "Preferences"] as const;
export function MultiStepForm() {
const [currentStep, setCurrentStep] = useState(0);
const form = useForm<FullFormValues>({
resolver: zodResolver(fullSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
street: "",
city: "",
state: "",
zip: "",
newsletter: false,
theme: "system",
language: "en",
},
mode: "onTouched",
});
const nextStep = async () => {
// Validate only the current step's fields
const currentSchema = stepSchemas[currentStep];
const fields = Object.keys(currentSchema.shape) as (keyof FullFormValues)[];
const isValid = await form.trigger(fields);
if (isValid) {
setCurrentStep((s) => Math.min(s + 1, STEPS.length - 1));
}
};
const prevStep = () => {
setCurrentStep((s) => Math.max(s - 1, 0));
};
const onSubmit = async (data: FullFormValues) => {
console.log("Complete form data:", data);
};
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Step indicator */}
<nav aria-label="Form steps" className="flex gap-2">
{STEPS.map((step, i) => (
<div
key={step}
className={`flex-1 rounded-full h-2 ${
i <= currentStep ? "bg-primary" : "bg-muted"
}`}
/>
))}
</nav>
<h2 className="text-xl font-semibold">{STEPS[currentStep]}</h2>
{/* Step content */}
{currentStep === 0 && <PersonalInfoStep />}
{currentStep === 1 && <AddressStep />}
{currentStep === 2 && <PreferencesStep />}
{/* Navigation */}
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={currentStep === 0}
>
Previous
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={nextStep}>
Next
</Button>
) : (
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Submitting..." : "Complete"}
</Button>
)}
</div>
</form>
</FormProvider>
);
}
import { z } from "zod/v4";
// Custom error map for the entire form
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
// Handle specific issue codes
if (issue.code === z.ZodIssueCode.too_small && issue.minimum === 1) {
return { message: "This field is required" };
}
if (issue.code === z.ZodIssueCode.invalid_type && issue.expected === "string") {
return { message: "Please enter a valid value" };
}
// Fall back to default
return { message: ctx.defaultError };
};
// Apply globally
z.config({ customError: customErrorMap });
// Or per-schema with custom messages
const formSchema = z.object({
age: z
.number({ error: "Please enter a valid number" })
.int("Age must be a whole number")
.min(18, "You must be at least 18 years old")
.max(120, "Please enter a valid age"),
email: z.email("Please enter a valid email address"),
});
import { z } from "zod/v4";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
const uploadSchema = z.object({
title: z.string().min(1, "Title is required"),
image: z
.instanceof(FileList)
.refine((files) => files.length === 1, "Please select an image")
.refine(
(files) => files[0]?.size <= MAX_FILE_SIZE,
"Image must be less than 5MB"
)
.refine(
(files) => ACCEPTED_IMAGE_TYPES.includes(files[0]?.type),
"Only .jpg, .png, and .webp formats are supported"
),
});
type UploadFormValues = z.infer<typeof uploadSchema>;
export function UploadForm() {
const form = useForm<UploadFormValues>({
resolver: zodResolver(uploadSchema),
defaultValues: { title: "" },
});
const imageRef = form.register("image");
async function onSubmit(data: UploadFormValues) {
const formData = new FormData();
formData.append("title", data.title);
formData.append("image", data.image[0]);
await fetch("/api/upload", { method: "POST", body: formData });
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="image"
render={() => (
<FormItem>
<FormLabel>Image</FormLabel>
<FormControl>
<Input type="file" accept={ACCEPTED_IMAGE_TYPES.join(",")} {...imageRef} />
</FormControl>
<FormDescription>Max 5MB. JPG, PNG, or WebP.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
Upload
</Button>
</form>
</Form>
);
}
| Feature | Approach |
|---|---|
| Schema definition | z.object({...}) with Zod v4 |
| Type inference | z.infer<typeof schema> |
| Resolver | zodResolver(schema) from @hookform/resolvers/zod |
| Field rendering | <FormField> with render prop |
| Dynamic arrays | useFieldArray from react-hook-form |
| Multi-step | Schema composition + form.trigger(fields) |
| Async validation | z.string().refine(async () => ...) |
| File uploads | z.instanceof(FileList) + form.register() |
| Cross-field validation | schema.refine() or schema.superRefine() |