Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:46:22 +08:00
commit cc15a2f60e
7 changed files with 2303 additions and 0 deletions

View File

@@ -0,0 +1,657 @@
---
name: form-generator-rhf-zod
description: This skill should be used when generating React forms with React Hook Form, Zod validation, and shadcn/ui components. Applies when creating entity forms, character editors, location forms, data entry forms, or any form requiring client and server validation. Trigger terms include create form, generate form, build form, React Hook Form, RHF, Zod validation, form component, entity form, character form, data entry, form schema.
---
# Form Generator with React Hook Form & Zod
Generate production-ready React forms using React Hook Form, Zod validation schemas, and accessible shadcn/ui form controls. This skill creates forms with client-side and server-side validation, proper TypeScript types, and consistent error handling.
## When to Use This Skill
Apply this skill when:
- Creating forms for entities (characters, locations, items, factions)
- Building data entry interfaces with validation requirements
- Generating forms with complex field types and conditional logic
- Setting up forms that need both client and server validation
- Creating accessible forms with proper ARIA attributes
- Building forms with multi-step or wizard patterns
## Resources Available
### Scripts
**scripts/generate_form.py** - Generates form component, Zod schema, and server action from field specifications.
Usage:
```bash
python scripts/generate_form.py --name CharacterForm --fields fields.json --output components/forms
```
**scripts/generate_zod_schema.py** - Converts field specifications to Zod schema with validation rules.
Usage:
```bash
python scripts/generate_zod_schema.py --fields fields.json --output lib/schemas
```
### References
**references/rhf-patterns.md** - React Hook Form patterns, hooks, and best practices
**references/zod-validation.md** - Zod schema patterns, refinements, and custom validators
**references/shadcn-form-controls.md** - shadcn/ui form component usage and examples
**references/server-actions.md** - Server action patterns for form submission
### Assets
**assets/form-template.tsx** - Base form component template with RHF setup
**assets/field-templates/** - Individual field component templates (Input, Textarea, Select, Checkbox, etc.)
**assets/validation-schemas.ts** - Common Zod validation patterns
**assets/form-utils.ts** - Form utility functions (formatters, transformers, validators)
## Form Generation Process
### Step 1: Define Field Specifications
Create a field specification file describing form fields, types, validation rules, and UI properties.
Field specification format:
```json
{
"fields": [
{
"name": "characterName",
"label": "Character Name",
"type": "text",
"required": true,
"validation": {
"minLength": 2,
"maxLength": 100,
"pattern": "^[a-zA-Z\\s'-]+$"
},
"placeholder": "Enter character name",
"helpText": "The character's full name as it appears in your world"
},
{
"name": "age",
"label": "Age",
"type": "number",
"required": false,
"validation": {
"min": 0,
"max": 10000
}
},
{
"name": "faction",
"label": "Faction",
"type": "select",
"required": true,
"options": "dynamic",
"optionsSource": "api.getFactions()"
},
{
"name": "biography",
"label": "Biography",
"type": "textarea",
"required": false,
"validation": {
"maxLength": 5000
},
"rows": 8
}
],
"formOptions": {
"submitLabel": "Create Character",
"resetLabel": "Clear Form",
"showReset": true,
"successMessage": "Character created successfully",
"errorMessage": "Failed to create character"
}
}
```
### Step 2: Generate Zod Schema
Use scripts/generate_zod_schema.py to create type-safe validation schema:
```bash
python scripts/generate_zod_schema.py --fields character-fields.json --output lib/schemas/character.ts
```
Generated schema includes:
- Field-level validation rules
- Custom refinements and transformations
- Type inference for TypeScript
- Error message customization
- Server-side validation support
### Step 3: Generate Form Component
Use scripts/generate_form.py to create React Hook Form component:
```bash
python scripts/generate_form.py --name CharacterForm --fields character-fields.json --output components/forms
```
Generated component includes:
- React Hook Form setup with useForm hook
- Zod schema resolver integration
- shadcn/ui FormField components
- Proper TypeScript types inferred from schema
- Accessible form controls with ARIA labels
- Error display with FormMessage components
- Form submission handler with loading states
- Success/error toast notifications
### Step 4: Create Server Action
Generate server action for form submission with server-side validation:
```typescript
'use server'
import { z } from 'zod'
import { characterSchema } from '@/lib/schemas/character'
import { createCharacter } from '@/lib/db/characters'
export async function createCharacterAction(data: z.infer<typeof characterSchema>) {
// Server-side validation
const validated = characterSchema.safeParse(data)
if (!validated.success) {
return {
success: false,
errors: validated.error.flatten().fieldErrors
}
}
// Database operation
const character = await createCharacter(validated.data)
return {
success: true,
data: character
}
}
```
### Step 5: Integrate Form into Page
Import and use generated form component in page or parent component:
```tsx
import { CharacterForm } from '@/components/forms/CharacterForm'
export default function CreateCharacterPage() {
return (
<div className="container max-w-2xl py-8">
<h1 className="text-3xl font-bold mb-6">Create New Character</h1>
<CharacterForm />
</div>
)
}
```
## Field Type Support
Supported field types and their shadcn/ui mappings:
- **text** → Input (type="text")
- **email** → Input (type="email")
- **password** → Input (type="password")
- **number** → Input (type="number")
- **tel** → Input (type="tel")
- **url** → Input (type="url")
- **textarea** → Textarea
- **select** → Select with SelectTrigger/SelectContent
- **multiselect** → MultiSelect custom component
- **checkbox** → Checkbox
- **radio** → RadioGroup with RadioGroupItem
- **switch** → Switch
- **date** → DatePicker (Popover + Calendar)
- **datetime** → DateTimePicker custom component
- **file** → Input (type="file")
- **combobox** → Combobox (Command + Popover)
- **tags** → TagInput custom component
- **slider** → Slider
- **color** → ColorPicker custom component
## Validation Patterns
Common validation patterns using Zod:
### String Validation
```typescript
// Required with length constraints
z.string().min(2, "Too short").max(100, "Too long")
// Email
z.string().email("Invalid email")
// URL
z.string().url("Invalid URL")
// Pattern matching
z.string().regex(/^[a-zA-Z]+$/, "Letters only")
// Trimmed strings
z.string().trim().min(1)
// Custom transformation
z.string().transform(val => val.toLowerCase())
```
### Number Validation
```typescript
// Range validation
z.number().min(0).max(100)
// Integer only
z.number().int("Must be whole number")
// Positive numbers
z.number().positive("Must be positive")
// Custom refinement
z.number().refine(val => val % 5 === 0, "Must be multiple of 5")
```
### Array Validation
```typescript
// Array with min/max items
z.array(z.string()).min(1, "Select at least one").max(5, "Too many")
// Non-empty array
z.array(z.string()).nonempty("Required")
```
### Object Validation
```typescript
// Nested objects
z.object({
address: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/)
})
})
```
### Conditional Validation
```typescript
// Refine with cross-field validation
z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"]
})
```
### Optional and Nullable Fields
```typescript
// Optional (can be undefined)
z.string().optional()
// Nullable (can be null)
z.string().nullable()
// Optional with default
z.string().default("default value")
```
## Form Patterns
### Basic Form Structure
```tsx
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
const formSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email()
})
type FormValues = z.infer<typeof formSchema>
export function ExampleForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: ''
}
})
async function onSubmit(values: FormValues) {
try {
const result = await submitAction(values)
if (result.success) {
toast.success('Submitted successfully')
form.reset()
} else {
toast.error(result.message)
}
} catch (error) {
toast.error('An error occurred')
}
}
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="Enter name" {...field} />
</FormControl>
<FormDescription>Your display name</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
</form>
</Form>
)
}
```
### Array Fields with useFieldArray
```tsx
import { useFieldArray } from 'react-hook-form'
import { Button } from '@/components/ui/button'
// In schema
const formSchema = z.object({
tags: z.array(z.object({
value: z.string().min(1)
})).min(1)
})
// In component
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'tags'
})
// In JSX
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<FormField
control={form.control}
name={`tags.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="button" variant="destructive" size="icon" onClick={() => remove(index)}>
X
</Button>
</div>
))}
<Button type="button" onClick={() => append({ value: '' })}>
Add Tag
</Button>
```
### File Upload with Preview
```tsx
const [preview, setPreview] = useState<string | null>(null)
<FormField
control={form.control}
name="avatar"
render={({ field: { value, onChange, ...field } }) => (
<FormItem>
<FormLabel>Avatar</FormLabel>
<FormControl>
<Input
type="file"
accept="image/*"
{...field}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
onChange(file)
const reader = new FileReader()
reader.onloadend = () => setPreview(reader.result as string)
reader.readAsDataURL(file)
}
}}
/>
</FormControl>
{preview && (
<img src={preview} alt="Preview" className="mt-2 h-32 w-32 object-cover rounded" />
)}
<FormMessage />
</FormItem>
)}
/>
```
### Conditional Fields
```tsx
const showAdvanced = form.watch('showAdvanced')
<FormField
control={form.control}
name="showAdvanced"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel>Show Advanced Options</FormLabel>
</FormItem>
)}
/>
{showAdvanced && (
<FormField
control={form.control}
name="advancedOption"
render={({ field }) => (
<FormItem>
<FormLabel>Advanced Option</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
```
## Accessibility Considerations
Ensure forms are accessible by:
1. **Proper Labels**: Every form control must have an associated FormLabel
2. **Error Messages**: Use FormMessage to announce validation errors
3. **Descriptions**: Use FormDescription for helpful context
4. **Required Fields**: Mark required fields visually and in ARIA attributes
5. **Focus Management**: Ensure logical tab order and focus indicators
6. **Keyboard Navigation**: All controls operable via keyboard
7. **ARIA Attributes**: FormField automatically sets aria-describedby and aria-invalid
8. **Error Summary**: Consider adding error summary at top of form for screen readers
## Testing Generated Forms
Test forms using React Testing Library and Vitest:
```tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CharacterForm } from './CharacterForm'
describe('CharacterForm', () => {
it('validates required fields', async () => {
render(<CharacterForm />)
const submitButton = screen.getByRole('button', { name: /submit/i })
await userEvent.click(submitButton)
expect(await screen.findByText(/name is required/i)).toBeInTheDocument()
})
it('submits valid data', async () => {
const mockSubmit = vi.fn()
render(<CharacterForm onSubmit={mockSubmit} />)
await userEvent.type(screen.getByLabelText(/name/i), 'Aragorn')
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
name: 'Aragorn'
})
})
})
})
```
## Common Use Cases for Worldbuilding
### Character Creation Form
Fields: name, race, faction, class, age, appearance, biography, relationships, attributes, inventory
### Location Form
Fields: name, type, region, coordinates, climate, population, government, description, points of interest
### Item/Artifact Form
Fields: name, type, rarity, owner, location, properties, history, magical effects, value
### Event/Timeline Form
Fields: title, date, location, participants, description, consequences, related events
### Faction/Organization Form
Fields: name, type, leader, headquarters, goals, allies, enemies, members, history
## Implementation Checklist
When generating forms, ensure:
- [ ] Zod schema created with all validation rules
- [ ] Form component uses zodResolver
- [ ] All field types mapped to appropriate shadcn/ui components
- [ ] FormField used for each field with proper render prop
- [ ] FormLabel, FormControl, FormMessage included for each field
- [ ] Form submission handler with error handling
- [ ] Loading states during submission
- [ ] Success/error feedback (toasts or messages)
- [ ] Server action created with server-side validation
- [ ] TypeScript types inferred from Zod schema
- [ ] Accessibility attributes present
- [ ] Form reset after successful submission
- [ ] Proper default values set
## Dependencies Required
Ensure these packages are installed:
```bash
npm install react-hook-form @hookform/resolvers zod
npm install sonner # for toast notifications
```
shadcn/ui components needed:
```bash
npx shadcn-ui@latest add form button input textarea select checkbox radio-group switch slider
```
## Best Practices
1. **Co-locate validation**: Keep Zod schemas close to form components
2. **Reuse schemas**: Share schemas between client and server validation
3. **Type inference**: Use `z.infer<typeof schema>` for TypeScript types
4. **Granular validation**: Validate on blur for better UX
5. **Optimistic updates**: Show success state before server confirmation when appropriate
6. **Error recovery**: Allow users to easily fix validation errors
7. **Progress indication**: Show loading states during async operations
8. **Data persistence**: Consider auto-saving drafts for long forms
9. **Field dependencies**: Use form.watch() for conditional fields
10. **Performance**: Use mode: 'onBlur' or 'onChange' based on form complexity
## Troubleshooting
**Issue**: Form not submitting
- Check handleSubmit is wrapping onSubmit
- Verify zodResolver is configured
- Check for validation errors in form state
**Issue**: Validation not working
- Ensure schema matches field names exactly
- Check resolver is zodResolver(schema)
- Verify field is registered with FormField
**Issue**: TypeScript errors
- Use z.infer<typeof schema> for type inference
- Ensure form values type matches schema type
- Check FormField generic type matches field value type
**Issue**: Field not updating
- Verify field spread {...field} is applied
- Check value/onChange are not overridden incorrectly
- Use field.value and field.onChange for controlled components
## Additional Resources
Consult references/ directory for detailed patterns:
- references/rhf-patterns.md - Advanced React Hook Form patterns
- references/zod-validation.md - Complex validation scenarios
- references/shadcn-form-controls.md - All form component variants
- references/server-actions.md - Server-side form handling
Use assets/ directory for starting templates:
- assets/form-template.tsx - Copy and customize
- assets/field-templates/ - Individual field implementations
- assets/validation-schemas.ts - Common validation patterns

View File

@@ -0,0 +1,159 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
// Define your schema
const formSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
email: z.string().email('Invalid email address'),
// Add more fields as needed
})
// Infer TypeScript type from schema
type FormValues = z.infer<typeof formSchema>
interface ExampleFormProps {
defaultValues?: Partial<FormValues>
onSuccess?: (data: FormValues) => void
}
export function ExampleForm({ defaultValues, onSuccess }: ExampleFormProps) {
// Initialize form with React Hook Form
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
...defaultValues,
},
mode: 'onBlur', // Validate on blur for better UX
})
// Form submission handler
async function onSubmit(values: FormValues) {
try {
// Call your server action or API endpoint
const result = await submitFormAction(values)
if (result.success) {
toast.success('Form submitted successfully')
form.reset()
onSuccess?.(values)
} else {
// Handle server-side validation errors
if (result.errors) {
Object.entries(result.errors).forEach(([field, message]) => {
form.setError(field as keyof FormValues, {
type: 'server',
message: message as string,
})
})
}
toast.error(result.message || 'Failed to submit form')
}
} catch (error) {
console.error('Form submission error:', error)
toast.error('An unexpected error occurred')
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Name Field */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Enter your name" {...field} />
</FormControl>
<FormDescription>
This is your display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Email Field */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Add more FormField components as needed */}
{/* Root Error Display */}
{form.formState.errors.root && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{form.formState.errors.root.message}
</div>
)}
{/* Submit Button */}
<div className="flex gap-3">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => form.reset()}
disabled={form.formState.isSubmitting}
>
Reset
</Button>
</div>
</form>
</Form>
)
}
// Placeholder server action - replace with actual implementation
async function submitFormAction(data: FormValues) {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000))
// Example server-side validation
const validated = formSchema.safeParse(data)
if (!validated.success) {
return {
success: false,
errors: validated.error.flatten().fieldErrors,
}
}
// Perform database operation
// const result = await db.insert(...)
return {
success: true,
data: validated.data,
}
}

View File

@@ -0,0 +1,631 @@
# React Hook Form Patterns Reference
## Core Hooks
### useForm
Main hook for form management:
```tsx
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '' },
mode: 'onBlur', // 'onChange' | 'onBlur' | 'onSubmit' | 'onTouched' | 'all'
reValidateMode: 'onChange', // When to re-validate after first error
criteriaMode: 'firstError', // 'firstError' | 'all'
shouldFocusError: true,
shouldUnregister: false,
shouldUseNativeValidation: false,
delayError: 500 // Delay error display (ms)
})
```
### useFieldArray
Manage dynamic field arrays:
```tsx
const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
control: form.control,
name: 'items',
keyName: 'id' // Custom key name (default: 'id')
})
```
### useWatch
Watch field values without re-rendering entire form:
```tsx
const watchedValue = useWatch({
control: form.control,
name: 'fieldName',
defaultValue: 'default'
})
// Watch multiple fields
const [field1, field2] = useWatch({
control: form.control,
name: ['field1', 'field2']
})
// Watch all fields
const allValues = useWatch({ control: form.control })
```
### useFormState
Access form state without subscribing to all changes:
```tsx
const { isDirty, isValid, errors, isSubmitting } = useFormState({
control: form.control
})
```
### useController
Lower-level field control:
```tsx
const { field, fieldState, formState } = useController({
name: 'fieldName',
control: form.control,
rules: { required: true },
defaultValue: ''
})
```
## Form Validation Modes
### onSubmit (Default)
- Validates on form submission
- Best for simple forms
- Minimal re-renders
- Delayed feedback
```tsx
const form = useForm({ mode: 'onSubmit' })
```
### onBlur
- Validates when field loses focus
- Good balance of UX and performance
- Recommended for most forms
```tsx
const form = useForm({ mode: 'onBlur' })
```
### onChange
- Validates on every keystroke
- Real-time feedback
- More re-renders
- Good for complex validation
```tsx
const form = useForm({ mode: 'onChange' })
```
### onTouched
- Validates after field is touched then on every change
- Progressive enhancement approach
```tsx
const form = useForm({ mode: 'onTouched' })
```
### all
- Validates on all events
- Most feedback but most re-renders
```tsx
const form = useForm({ mode: 'all' })
```
## Form Methods
### Form State Access
```tsx
// Check if form is dirty
form.formState.isDirty
// Check if form is valid
form.formState.isValid
// Check if submitting
form.formState.isSubmitting
// Get all errors
form.formState.errors
// Check if field is touched
form.formState.touchedFields.fieldName
// Get dirty fields
form.formState.dirtyFields
// Submit count
form.formState.submitCount
```
### Form Actions
```tsx
// Submit form
form.handleSubmit(onSubmit, onError)
// Reset form
form.reset() // to default values
form.reset({ name: 'New Name' }) // to specific values
// Set value
form.setValue('fieldName', 'value', {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true
})
// Get value
const value = form.getValues('fieldName')
const allValues = form.getValues()
// Clear errors
form.clearErrors() // all errors
form.clearErrors('fieldName') // specific field
// Set error
form.setError('fieldName', {
type: 'manual',
message: 'Error message'
})
// Trigger validation
form.trigger() // all fields
form.trigger('fieldName') // specific field
form.trigger(['field1', 'field2']) // multiple fields
// Set focus
form.setFocus('fieldName')
```
## Advanced Patterns
### Dependent Fields
```tsx
const faction = form.watch('faction')
<FormField
control={form.control}
name="rank"
render={({ field }) => (
<FormItem>
<FormLabel>Rank</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select rank" />
</SelectTrigger>
</FormControl>
<SelectContent>
{getRanksForFaction(faction).map(rank => (
<SelectItem key={rank.id} value={rank.id}>
{rank.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
```
### Async Validation
```tsx
const schema = z.object({
username: z.string()
.min(3)
.refine(async (username) => {
const available = await checkUsernameAvailability(username)
return available
}, {
message: 'Username already taken'
})
})
// Use mode: 'onBlur' for better UX with async validation
const form = useForm({
resolver: zodResolver(schema),
mode: 'onBlur'
})
```
### Form Context
Share form between multiple components:
```tsx
// Parent component
import { FormProvider } from 'react-hook-form'
const form = useForm()
<FormProvider {...form}>
<ChildComponent1 />
<ChildComponent2 />
</FormProvider>
// Child component
import { useFormContext } from 'react-hook-form'
function ChildComponent() {
const form = useFormContext()
// Use form methods
}
```
### Controlled Components
```tsx
import { Controller } from 'react-hook-form'
<Controller
name="customField"
control={form.control}
render={({ field, fieldState, formState }) => (
<CustomComponent
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
```
### Transform Values on Submit
```tsx
const onSubmit = form.handleSubmit((data) => {
const transformed = {
...data,
age: parseInt(data.age),
tags: data.tags.map(t => t.toLowerCase()),
createdAt: new Date()
}
// Submit transformed data
})
```
### Dirty Field Tracking
```tsx
const dirtyFields = form.formState.dirtyFields
function onSubmit(data: FormValues) {
// Only submit changed fields
const changedData = Object.keys(dirtyFields).reduce((acc, key) => {
acc[key] = data[key]
return acc
}, {} as Partial<FormValues>)
await updateEntity(id, changedData)
}
```
### Multi-Step Form
```tsx
const [step, setStep] = useState(1)
function onSubmit(data: FormValues) {
if (step < 3) {
setStep(step + 1)
} else {
// Final submission
submitData(data)
}
}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{step === 1 && <Step1Fields control={form.control} />}
{step === 2 && <Step2Fields control={form.control} />}
{step === 3 && <Step3Fields control={form.control} />}
<div className="flex gap-2">
{step > 1 && (
<Button type="button" onClick={() => setStep(step - 1)}>
Previous
</Button>
)}
<Button type="submit">
{step < 3 ? 'Next' : 'Submit'}
</Button>
</div>
</form>
</Form>
```
### Reset with Default Values
```tsx
// Reset to specific values after successful submission
async function onSubmit(data: FormValues) {
const result = await createEntity(data)
if (result.success) {
form.reset({
name: '',
type: 'default',
// Keep some values
category: data.category
})
}
}
```
### Error Handling
```tsx
async function onSubmit(data: FormValues) {
try {
const result = await submitAction(data)
if (!result.success) {
// Set server errors
if (result.errors) {
Object.entries(result.errors).forEach(([field, message]) => {
form.setError(field as any, {
type: 'server',
message: message as string
})
})
}
// Set root error for general issues
if (result.message) {
form.setError('root', {
type: 'server',
message: result.message
})
}
} else {
toast.success('Saved successfully')
form.reset()
}
} catch (error) {
form.setError('root', {
type: 'server',
message: 'An unexpected error occurred'
})
}
}
// Display root error
{form.formState.errors.root && (
<Alert variant="destructive">
<AlertDescription>
{form.formState.errors.root.message}
</AlertDescription>
</Alert>
)}
```
### Optimistic Updates
```tsx
const [optimisticData, setOptimisticData] = useState<Entity | null>(null)
async function onSubmit(data: FormValues) {
// Show optimistic state
setOptimisticData(data as Entity)
toast.success('Saving...')
try {
const result = await saveEntity(data)
if (result.success) {
// Confirm success
setOptimisticData(result.data)
toast.success('Saved successfully')
} else {
// Revert optimistic state
setOptimisticData(null)
toast.error('Save failed')
}
} catch (error) {
setOptimisticData(null)
toast.error('An error occurred')
}
}
```
### Auto-Save Draft
```tsx
import { useEffect } from 'react'
import { useDebouncedCallback } from 'use-debounce'
const saveDraft = useDebouncedCallback(async (data: FormValues) => {
await saveDraftToStorage(data)
}, 1000)
useEffect(() => {
const subscription = form.watch((data) => {
if (form.formState.isDirty) {
saveDraft(data as FormValues)
}
})
return () => subscription.unsubscribe()
}, [form.watch, form.formState.isDirty])
// Load draft on mount
useEffect(() => {
const draft = loadDraftFromStorage()
if (draft) {
form.reset(draft)
}
}, [])
```
### Conditional Required Fields
```tsx
const schema = z.object({
type: z.enum(['character', 'location']),
characterRace: z.string().optional(),
locationType: z.string().optional()
}).refine(
(data) => {
if (data.type === 'character') {
return data.characterRace !== undefined && data.characterRace.length > 0
}
return true
},
{
message: 'Race is required for characters',
path: ['characterRace']
}
).refine(
(data) => {
if (data.type === 'location') {
return data.locationType !== undefined && data.locationType.length > 0
}
return true
},
{
message: 'Location type is required',
path: ['locationType']
}
)
```
### Field Masking/Formatting
```tsx
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
const formatted = formatPhoneNumber(e.target.value)
field.onChange(formatted)
}}
placeholder="(123) 456-7890"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
function formatPhoneNumber(value: string): string {
const numbers = value.replace(/\D/g, '')
const match = numbers.match(/^(\d{0,3})(\d{0,3})(\d{0,4})$/)
if (!match) return value
const formatted = [match[1], match[2], match[3]]
.filter(Boolean)
.join('-')
return formatted
}
```
## Performance Optimization
### Isolate Re-renders
```tsx
// Bad: Entire form re-renders on field change
const value = form.watch('field')
// Good: Only component re-renders
function WatchedField() {
const value = useWatch({ control: form.control, name: 'field' })
return <div>{value}</div>
}
```
### Use FormField for Each Field
```tsx
// FormField isolates re-renders to individual fields
<FormField
control={form.control}
name="field"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
```
### Memoize Expensive Computations
```tsx
import { useMemo } from 'react'
const options = useMemo(() => {
return computeExpensiveOptions()
}, [dependencies])
```
## Testing Patterns
```tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MyForm } from './MyForm'
describe('MyForm', () => {
it('submits form with valid data', async () => {
const onSubmit = vi.fn()
render(<MyForm onSubmit={onSubmit} />)
await userEvent.type(screen.getByLabelText(/name/i), 'Test Name')
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Test Name'
})
})
})
it('shows validation errors', async () => {
render(<MyForm />)
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
expect(await screen.findByText(/name is required/i)).toBeInTheDocument()
})
it('clears errors on valid input', async () => {
render(<MyForm />)
const input = screen.getByLabelText(/name/i)
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
expect(await screen.findByText(/name is required/i)).toBeInTheDocument()
await userEvent.type(input, 'Valid Name')
await waitFor(() => {
expect(screen.queryByText(/name is required/i)).not.toBeInTheDocument()
})
})
})
```

View File

@@ -0,0 +1,784 @@
# Zod Validation Reference
## Basic Types
### Strings
```typescript
// Basic string
z.string()
// With constraints
z.string().min(3, "Minimum 3 characters")
z.string().max(100, "Maximum 100 characters")
z.string().length(10, "Must be exactly 10 characters")
// Email
z.string().email("Invalid email address")
// URL
z.string().url("Invalid URL")
// UUID
z.string().uuid("Invalid UUID")
// CUID
z.string().cuid("Invalid CUID")
// Regex pattern
z.string().regex(/^[a-zA-Z]+$/, "Letters only")
// DateTime ISO
z.string().datetime("Invalid datetime")
// IP Address
z.string().ip("Invalid IP address")
z.string().ip({ version: 'v4' }) // IPv4 only
z.string().ip({ version: 'v6' }) // IPv6 only
// Trim whitespace
z.string().trim()
// Transform to lowercase
z.string().toLowerCase()
// Transform to uppercase
z.string().toUpperCase()
// Empty strings as null
z.string().nullable()
// Starts with
z.string().startsWith("prefix", "Must start with 'prefix'")
// Ends with
z.string().endsWith("suffix", "Must end with 'suffix'")
// Includes
z.string().includes("substring", "Must contain 'substring'")
```
### Numbers
```typescript
// Basic number
z.number()
// Integer only
z.number().int("Must be an integer")
// Positive numbers
z.number().positive("Must be positive")
// Non-negative (includes 0)
z.number().nonnegative("Must be 0 or greater")
// Negative numbers
z.number().negative("Must be negative")
// Non-positive (includes 0)
z.number().nonpositive("Must be 0 or less")
// Range
z.number().min(0, "Minimum is 0")
z.number().max(100, "Maximum is 100")
z.number().gte(0).lte(100) // Greater/less than or equal
// Multiple of
z.number().multipleOf(5, "Must be multiple of 5")
// Finite (not Infinity or NaN)
z.number().finite("Must be finite")
// Safe integer
z.number().safe("Must be safe integer")
```
### Booleans
```typescript
// Basic boolean
z.boolean()
// Coerce from string
z.coerce.boolean() // "true", "false", "1", "0" → boolean
```
### Dates
```typescript
// Date object
z.date()
// Min/max dates
z.date().min(new Date("2020-01-01"), "Too early")
z.date().max(new Date("2030-12-31"), "Too late")
// Coerce from string or number
z.coerce.date()
```
### Enums
```typescript
// String enum
z.enum(['character', 'location', 'item'])
// With error message
z.enum(['character', 'location', 'item'], {
errorMap: () => ({ message: "Invalid entity type" })
})
// Native enum
enum Role {
Admin = 'ADMIN',
User = 'USER'
}
z.nativeEnum(Role)
```
### Literals
```typescript
// Single literal value
z.literal('admin')
z.literal(42)
z.literal(true)
// Union of literals
z.union([
z.literal('small'),
z.literal('medium'),
z.literal('large')
])
```
## Complex Types
### Objects
```typescript
// Basic object
z.object({
name: z.string(),
age: z.number()
})
// Nested objects
z.object({
user: z.object({
name: z.string(),
email: z.string().email()
}),
address: z.object({
street: z.string(),
city: z.string()
})
})
// Optional properties
z.object({
name: z.string(),
nickname: z.string().optional()
})
// Partial (all properties optional)
const schema = z.object({ name: z.string(), age: z.number() })
const partialSchema = schema.partial()
// Pick specific keys
schema.pick({ name: true })
// Omit specific keys
schema.omit({ age: true })
// Extend object
const baseSchema = z.object({ name: z.string() })
const extendedSchema = baseSchema.extend({
age: z.number()
})
// Merge objects
const merged = schema1.merge(schema2)
// Passthrough (allow unknown keys)
z.object({ name: z.string() }).passthrough()
// Strict (reject unknown keys)
z.object({ name: z.string() }).strict()
// Catchall (type for unknown keys)
z.object({ name: z.string() }).catchall(z.string())
```
### Arrays
```typescript
// Basic array
z.array(z.string())
// Array with min/max length
z.array(z.string()).min(1, "At least one item required")
z.array(z.string()).max(10, "Maximum 10 items")
z.array(z.string()).length(5, "Must have exactly 5 items")
// Non-empty array
z.array(z.string()).nonempty("Cannot be empty")
// Array of objects
z.array(z.object({
id: z.string(),
name: z.string()
}))
```
### Tuples
```typescript
// Fixed-length array with specific types
z.tuple([z.string(), z.number(), z.boolean()])
// With rest parameter
z.tuple([z.string(), z.number()]).rest(z.string())
```
### Records
```typescript
// Object with string keys and specific value type
z.record(z.string()) // Record<string, string>
z.record(z.number()) // Record<string, number>
// With specific key type
z.record(z.enum(['a', 'b', 'c']), z.number())
```
### Maps & Sets
```typescript
// Map
z.map(z.string(), z.number()) // Map<string, number>
// Set
z.set(z.string()) // Set<string>
z.set(z.number()).min(3, "At least 3 items")
```
### Unions
```typescript
// Union of types
z.union([z.string(), z.number()])
// Discriminated union
z.discriminatedUnion('type', [
z.object({ type: z.literal('character'), race: z.string() }),
z.object({ type: z.literal('location'), region: z.string() })
])
```
### Intersections
```typescript
// Combine multiple schemas
const Name = z.object({ name: z.string() })
const Age = z.object({ age: z.number() })
const Person = z.intersection(Name, Age)
// or
const Person = Name.and(Age)
```
## Optional and Nullable
```typescript
// Optional (can be undefined)
z.string().optional() // string | undefined
// Nullable (can be null)
z.string().nullable() // string | null
// Both
z.string().nullish() // string | null | undefined
// With default value
z.string().default("default value")
z.number().default(0)
// Default from function
z.date().default(() => new Date())
```
## Refinements
### Basic Refinement
```typescript
// Custom validation
z.string().refine(
(val) => val.length > 5,
{ message: "Must be more than 5 characters" }
)
// Multiple refinements
z.string()
.refine((val) => val.includes('@'), "Must include @")
.refine((val) => val.endsWith('.com'), "Must end with .com")
```
### Async Refinement
```typescript
z.string().refine(
async (username) => {
const exists = await checkUsername(username)
return !exists
},
{ message: "Username already taken" }
)
```
### Cross-Field Validation
```typescript
z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords must match",
path: ["confirmPassword"] // Error shown on this field
}
)
// Multiple cross-field validations
z.object({
startDate: z.date(),
endDate: z.date(),
type: z.enum(['event', 'period'])
}).refine(
(data) => data.startDate < data.endDate,
{
message: "End date must be after start date",
path: ["endDate"]
}
).refine(
(data) => {
if (data.type === 'event') {
return data.startDate.getTime() === data.endDate.getTime()
}
return true
},
{
message: "Events must have same start and end date",
path: ["endDate"]
}
)
```
### Superrefine (Advanced)
```typescript
z.object({
age: z.number(),
faction: z.string()
}).superrefine((data, ctx) => {
if (data.age < 18 && data.faction === 'military') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must be 18+ to join military",
path: ["age"]
})
}
if (data.age > 1000 && !data.faction.startsWith('ancient')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Ancient beings must join ancient factions",
path: ["faction"]
})
}
})
```
## Transformations
```typescript
// Transform string to number
z.string().transform((val) => parseInt(val))
// Transform and validate
z.string()
.transform((val) => val.trim())
.pipe(z.string().min(1))
// Transform dates
z.string().transform((val) => new Date(val))
// Coerce types
z.coerce.number() // "123" → 123
z.coerce.boolean() // "true" → true
z.coerce.date() // "2023-01-01" → Date
// Chain transformations
z.string()
.transform((val) => val.trim())
.transform((val) => val.toLowerCase())
.transform((val) => val.split(','))
```
## Preprocess
```typescript
// Transform before validation
z.preprocess(
(val) => {
if (typeof val === 'string') {
return val.trim()
}
return val
},
z.string().min(1)
)
// Normalize dates
z.preprocess(
(val) => {
if (val instanceof Date) return val
if (typeof val === 'string') return new Date(val)
return val
},
z.date()
)
```
## Custom Error Messages
```typescript
// Field-level messages
z.string({
required_error: "Name is required",
invalid_type_error: "Name must be a string"
})
// Constraint messages
z.string()
.min(3, { message: "Minimum 3 characters" })
.max(100, { message: "Maximum 100 characters" })
// Error map for entire schema
const schema = z.object({
name: z.string(),
age: z.number()
}, {
errorMap: (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
return { message: "Please provide a valid value" }
}
return { message: ctx.defaultError }
}
})
```
## Parsing and Validation
```typescript
// Parse (throws on error)
try {
const result = schema.parse(data)
// result is typed
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.errors)
}
}
// Safe parse (returns result object)
const result = schema.safeParse(data)
if (result.success) {
console.log(result.data) // typed data
} else {
console.log(result.error) // ZodError
}
// Parse async
const result = await schema.parseAsync(data)
// Safe parse async
const result = await schema.safeParseAsync(data)
```
## Error Handling
```typescript
// Get all errors
const result = schema.safeParse(data)
if (!result.success) {
result.error.issues.forEach(issue => {
console.log(issue.path) // ['field', 'name']
console.log(issue.message) // "Invalid value"
console.log(issue.code) // ZodIssueCode
})
}
// Flatten errors for forms
const result = schema.safeParse(data)
if (!result.success) {
const flattened = result.error.flatten()
console.log(flattened.fieldErrors) // { name: ["error"], age: ["error"] }
console.log(flattened.formErrors) // ["general error"]
}
// Format errors for React Hook Form
const result = schema.safeParse(data)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
Object.entries(errors).forEach(([field, messages]) => {
form.setError(field as any, {
type: 'validation',
message: messages?.[0] || 'Invalid value'
})
})
}
```
## Common Patterns for Worldbuilding
### Character Schema
```typescript
const characterSchema = z.object({
name: z.string()
.min(2, "Name too short")
.max(100, "Name too long")
.regex(/^[a-zA-Z\s'-]+$/, "Invalid characters in name"),
race: z.string().min(1, "Race required"),
age: z.number()
.int("Age must be whole number")
.min(0, "Age cannot be negative")
.max(10000, "Age unrealistic"),
faction: z.string().optional(),
attributes: z.object({
strength: z.number().min(0).max(100),
intelligence: z.number().min(0).max(100),
charisma: z.number().min(0).max(100)
}).optional(),
biography: z.string()
.max(5000, "Biography too long")
.optional(),
relationships: z.array(z.object({
characterId: z.string().uuid(),
type: z.enum(['ally', 'enemy', 'neutral', 'family']),
description: z.string().optional()
})).optional()
})
```
### Location Schema
```typescript
const locationSchema = z.object({
name: z.string().min(2).max(200),
type: z.enum([
'city', 'town', 'village', 'dungeon', 'forest',
'mountain', 'ocean', 'desert', 'ruins', 'landmark'
]),
region: z.string().optional(),
coordinates: z.object({
x: z.number(),
y: z.number(),
z: z.number().optional()
}).optional(),
climate: z.enum([
'tropical', 'temperate', 'arctic', 'desert', 'varied'
]).optional(),
population: z.number()
.int()
.nonnegative()
.optional(),
government: z.string().optional(),
description: z.string().max(10000).optional(),
pointsOfInterest: z.array(z.object({
name: z.string(),
description: z.string(),
type: z.string()
})).optional()
}).refine(
(data) => {
// Cities should have population
if (data.type === 'city' && !data.population) {
return false
}
return true
},
{
message: "Cities must have population defined",
path: ["population"]
}
)
```
### Timeline Event Schema
```typescript
const eventSchema = z.object({
title: z.string().min(1).max(200),
date: z.union([
z.date(),
z.object({
year: z.number(),
month: z.number().min(1).max(12).optional(),
day: z.number().min(1).max(31).optional()
})
]),
endDate: z.union([
z.date(),
z.object({
year: z.number(),
month: z.number().min(1).max(12).optional(),
day: z.number().min(1).max(31).optional()
})
]).optional(),
location: z.string().optional(),
participants: z.array(z.string()).optional(),
description: z.string().max(5000).optional(),
consequences: z.string().max(5000).optional(),
relatedEvents: z.array(z.string()).optional(),
tags: z.array(z.string()).optional()
}).refine(
(data) => {
// If endDate exists, validate it's after startDate
if (data.endDate) {
// Compare dates/objects appropriately
return true // Implement comparison logic
}
return true
},
{
message: "End date must be after start date",
path: ["endDate"]
}
)
```
### Item/Artifact Schema
```typescript
const itemSchema = z.object({
name: z.string().min(1).max(200),
type: z.enum([
'weapon', 'armor', 'artifact', 'consumable',
'tool', 'treasure', 'document', 'other'
]),
rarity: z.enum([
'common', 'uncommon', 'rare', 'epic', 'legendary', 'unique'
]),
owner: z.string().uuid().optional(),
location: z.string().uuid().optional(),
properties: z.record(z.string(), z.any()).optional(),
magicalEffects: z.array(z.object({
name: z.string(),
description: z.string(),
potency: z.number().min(1).max(10).optional()
})).optional(),
value: z.number().nonnegative().optional(),
weight: z.number().nonnegative().optional(),
history: z.string().max(5000).optional(),
requiresAttunement: z.boolean().default(false)
}).refine(
(data) => {
// Magical items should have magical effects
if (data.requiresAttunement && (!data.magicalEffects || data.magicalEffects.length === 0)) {
return false
}
return true
},
{
message: "Items requiring attunement must have magical effects",
path: ["magicalEffects"]
}
)
```
## Type Inference
```typescript
// Infer TypeScript type from schema
type Character = z.infer<typeof characterSchema>
// Infer input type (before transformations)
type CharacterInput = z.input<typeof characterSchema>
// Infer output type (after transformations)
type CharacterOutput = z.output<typeof characterSchema>
// Use in React Hook Form
const form = useForm<Character>({
resolver: zodResolver(characterSchema)
})
```
## Schema Composition
```typescript
// Base entity schema
const baseEntitySchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(200),
description: z.string().max(5000).optional(),
tags: z.array(z.string()).optional(),
createdAt: z.date(),
updatedAt: z.date()
})
// Extend for specific entities
const characterSchema = baseEntitySchema.extend({
type: z.literal('character'),
race: z.string(),
age: z.number().optional()
})
const locationSchema = baseEntitySchema.extend({
type: z.literal('location'),
coordinates: z.object({ x: z.number(), y: z.number() }).optional()
})
// Union of all entity types
const entitySchema = z.discriminatedUnion('type', [
characterSchema,
locationSchema
])
```