Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:25:27 +08:00
commit c3e8cbf0e4
22 changed files with 7157 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"name": "react-hook-form-zod",
"description": "Build type-safe validated forms in React using React Hook Form and Zod schema validation. Single schema works on both client and server for DRY validation with full TypeScript type inference via z.infer. Use when: building forms with validation, integrating shadcn/ui Form components, implementing multi-step wizards, handling dynamic field arrays with useFieldArray, or fixing uncontrolled to controlled warnings, resolver errors, async validation issues.",
"version": "1.0.0",
"author": {
"name": "Jeremy Dawes",
"email": "jeremy@jezweb.net"
},
"skills": [
"./"
]
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# react-hook-form-zod
Build type-safe validated forms in React using React Hook Form and Zod schema validation. Single schema works on both client and server for DRY validation with full TypeScript type inference via z.infer. Use when: building forms with validation, integrating shadcn/ui Form components, implementing multi-step wizards, handling dynamic field arrays with useFieldArray, or fixing uncontrolled to controlled warnings, resolver errors, async validation issues.

1430
SKILL.md Normal file

File diff suppressed because it is too large Load Diff

117
plugin.lock.json Normal file
View File

@@ -0,0 +1,117 @@
{
"$schema": "internal://schemas/plugin.lock.v1.json",
"pluginId": "gh:jezweb/claude-skills:skills/react-hook-form-zod",
"normalized": {
"repo": null,
"ref": "refs/tags/v20251128.0",
"commit": "6a98b1abacb4fef9ec2b9a73e5070be03bdb2307",
"treeHash": "32a72eb8e9e6ee37a5902c88aa426e3e2e5461229af42fc2d9d5777f6864586e",
"generatedAt": "2025-11-28T10:18:59.225238Z",
"toolVersion": "publish_plugins.py@0.2.0"
},
"origin": {
"remote": "git@github.com:zhongweili/42plugin-data.git",
"branch": "master",
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
},
"manifest": {
"name": "react-hook-form-zod",
"description": "Build type-safe validated forms in React using React Hook Form and Zod schema validation. Single schema works on both client and server for DRY validation with full TypeScript type inference via z.infer. Use when: building forms with validation, integrating shadcn/ui Form components, implementing multi-step wizards, handling dynamic field arrays with useFieldArray, or fixing uncontrolled to controlled warnings, resolver errors, async validation issues.",
"version": "1.0.0"
},
"content": {
"files": [
{
"path": "README.md",
"sha256": "76a9cbe8f985f55b20b3d513b82085c5f7e4cf8c9dcdba5dfb5e90326f3b85d1"
},
{
"path": "SKILL.md",
"sha256": "96c94c95fe4d117fcff9e63c6b3fd650802379658bbed57f6850869f43339982"
},
{
"path": "references/shadcn-integration.md",
"sha256": "a6d88b6f1ad5dbe49ed8ac90a8f7a89a25fa0ee2fe7d8a6a6c495d008589a684"
},
{
"path": "references/top-errors.md",
"sha256": "49aebed96b309bdcb7e5fcecdb51f0d6b6e0da228e4414f6fc9fde36ac9940e8"
},
{
"path": "references/accessibility.md",
"sha256": "85bd88f7cee99dfd6bc403b6e778a97111a46dafa8dc40839380d5da4912f3dd"
},
{
"path": "references/performance-optimization.md",
"sha256": "0564005c55333ca7941a9df5d6595c3651d3ad79d5bda015da233427bc9502ce"
},
{
"path": "references/zod-schemas-guide.md",
"sha256": "b80dbc5667b0c186017fb2c6789517e9186cfa7413c7c480bd06c604b7108038"
},
{
"path": "references/links-to-official-docs.md",
"sha256": "312e33e6540a516095baf63b246a1c21e5686c749cad69872cb6603aa973c95c"
},
{
"path": "references/error-handling.md",
"sha256": "67088252a1c72cf0f4b1c38a1ded0496cb7e1c0cd4a8ed3c3c420706e268af53"
},
{
"path": "references/rhf-api-reference.md",
"sha256": "77630903d1f26f4e9422ba33a2fd212c4c6da757b7c4f31222f119af66dca306"
},
{
"path": "scripts/check-versions.sh",
"sha256": "a4c0c67680d70d34efee0d0c401eb34c56791419cdc360af60bdb0724e7e646d"
},
{
"path": ".claude-plugin/plugin.json",
"sha256": "266f98ea80f4e685feed2475d1369358a4e99354abe80112cf729e90896be942"
},
{
"path": "templates/multi-step-form.tsx",
"sha256": "8449065f80e6d4dc792c72a6ae15fa0f4e890f6a982ab36ac3fb04194c3021a4"
},
{
"path": "templates/shadcn-form.tsx",
"sha256": "b202f15315be8de0d269d955fa91a2e9ca733b10bffce64e18e8d727db3230a6"
},
{
"path": "templates/async-validation.tsx",
"sha256": "c26ec29a060b396a5c505531c75f31d241d2b2a1ac57c1a872e9af08fcaf0a2d"
},
{
"path": "templates/advanced-form.tsx",
"sha256": "ad890fc7bc8013f7752d3c9ac8d2e37509270f6e28a69e6b2e14eff1b3424957"
},
{
"path": "templates/dynamic-fields.tsx",
"sha256": "2d6c8bf02a14ffea8212ae60d907f84f379b19f3325905312d394ceb4ad34935"
},
{
"path": "templates/basic-form.tsx",
"sha256": "76866166e0469662b417cd559a02d878a4e192835aff1ac501b25768bb132d2d"
},
{
"path": "templates/package.json",
"sha256": "0bc845a64cff37e89fe854b7342d0fbcbf65b70e11c1d731c3b0f22f1d78733b"
},
{
"path": "templates/server-validation.ts",
"sha256": "6a3dfafb000e636b16bbb1adcc35603b2a358fffe7e033cf6dde2914dd8fbda4"
},
{
"path": "templates/custom-error-display.tsx",
"sha256": "afcf9664814269406a0f155e1e76c62ee6384de6fe91368b004e421f23c840e0"
}
],
"dirSha256": "32a72eb8e9e6ee37a5902c88aa426e3e2e5461229af42fc2d9d5777f6864586e"
},
"security": {
"scannedAt": null,
"scannerVersion": null,
"flags": []
}
}

359
references/accessibility.md Normal file
View File

@@ -0,0 +1,359 @@
# Accessibility (a11y) Best Practices
Complete guide for building accessible forms.
---
## WCAG Compliance
### Required Elements
1. **Labels** - Every input must have a label
2. **Error Messages** - Must be accessible to screen readers
3. **Focus Management** - Errors should be announced
4. **Keyboard Navigation** - Full keyboard support
---
## ARIA Attributes
### Essential ARIA
```typescript
<input
id="email"
type="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
aria-required="true"
/>
<span id="email-hint">We'll never share your email</span>
{errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
)}
```
### Live Regions for Error Announcements
```typescript
{Object.keys(errors).length > 0 && (
<div role="alert" aria-live="assertive" aria-atomic="true">
Form has {Object.keys(errors).length} errors. Please review.
</div>
)}
```
---
## Focus Management
### Focus First Error
```typescript
import { useEffect, useRef } from 'react'
const firstErrorRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (Object.keys(errors).length > 0) {
firstErrorRef.current?.focus()
}
}, [errors])
// In JSX
<input
ref={Object.keys(errors)[0] === 'email' ? firstErrorRef : undefined}
{...register('email')}
/>
```
### Using setFocus
```typescript
const onSubmit = async (data) => {
try {
await submitData(data)
} catch (error) {
setFocus('email') // Focus field programmatically
}
}
```
---
## Label Association
### Explicit Labels
```typescript
<label htmlFor="email">Email Address</label>
<input id="email" {...register('email')} />
```
### aria-label (When Visual Label Not Possible)
```typescript
<input
{...register('search')}
aria-label="Search products"
placeholder="Search..."
/>
```
### aria-labelledby (Multiple Labels)
```typescript
<h3 id="billing-heading">Billing Address</h3>
<input
{...register('billingStreet')}
aria-labelledby="billing-heading billing-street-label"
/>
<span id="billing-street-label">Street</span>
```
---
## Required Fields
### Visual Indicator
```typescript
<label htmlFor="email">
Email <span aria-label="required">*</span>
</label>
<input
id="email"
{...register('email')}
aria-required="true"
required
/>
```
### Legend for Required Fields
```typescript
<p className="required-legend">
<span aria-label="required">*</span> Required field
</p>
```
---
## Error Messaging
### Accessible Error Pattern
```typescript
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...register('password')}
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby={errors.password ? 'password-error' : 'password-hint'}
/>
<span id="password-hint" className="hint">
Must be at least 8 characters
</span>
{errors.password && (
<span id="password-error" role="alert" className="error">
{errors.password.message}
</span>
)}
</div>
```
---
## Fieldsets and Legends
### Grouping Related Fields
```typescript
<fieldset>
<legend>Contact Information</legend>
<div>
<label htmlFor="firstName">First Name</label>
<input id="firstName" {...register('firstName')} />
</div>
<div>
<label htmlFor="lastName">Last Name</label>
<input id="lastName" {...register('lastName')} />
</div>
</fieldset>
```
### Radio Groups
```typescript
<fieldset>
<legend>Choose your plan</legend>
<div>
<input
id="plan-basic"
type="radio"
value="basic"
{...register('plan')}
/>
<label htmlFor="plan-basic">Basic</label>
</div>
<div>
<input
id="plan-pro"
type="radio"
value="pro"
{...register('plan')}
/>
<label htmlFor="plan-pro">Pro</label>
</div>
</fieldset>
```
---
## Keyboard Navigation
### Tab Order
```typescript
// Ensure logical tab order with tabindex (use sparingly)
<input {...register('email')} tabIndex={1} />
<input {...register('password')} tabIndex={2} />
<button type="submit" tabIndex={3}>Submit</button>
```
### Skip Links
```typescript
<a href="#main-form" className="skip-link">
Skip to form
</a>
<form id="main-form">
{/* ... */}
</form>
```
---
## Button Accessibility
### Submit Button States
```typescript
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting ? 'true' : 'false'}
aria-live="polite"
>
{isSubmitting ? 'Submitting...' : 'Submit Form'}
</button>
```
### Icon Buttons
```typescript
<button type="button" aria-label="Remove item" onClick={remove}>
<TrashIcon aria-hidden="true" />
</button>
```
---
## Screen Reader Announcements
### Status Messages
```typescript
{isSubmitSuccessful && (
<div role="status" aria-live="polite">
Form submitted successfully!
</div>
)}
```
### Loading States
```typescript
{isSubmitting && (
<div role="status" aria-live="polite">
Submitting form, please wait...
</div>
)}
```
---
## Color Contrast
### WCAG AA Standards
- Normal text: 4.5:1 minimum
- Large text: 3:1 minimum
- UI components: 3:1 minimum
```css
/* Good contrast examples */
.error {
color: #c41e3a; /* Red */
background: #ffffff; /* White */
/* Contrast ratio: 5.77:1 ✓ */
}
.button {
color: #ffffff;
background: #0066cc;
/* Contrast ratio: 7.33:1 ✓ */
}
```
---
## Testing
### Automated Testing Tools
- **axe DevTools** - Browser extension
- **Lighthouse** - Chrome DevTools
- **WAVE** - Web accessibility evaluation tool
### Manual Testing
1. **Keyboard Navigation** - Tab through entire form
2. **Screen Reader** - Test with NVDA (Windows) or VoiceOver (Mac)
3. **Zoom** - Test at 200% zoom
4. **High Contrast** - Test in high contrast mode
---
## Accessibility Checklist
- [ ] All inputs have associated labels
- [ ] Required fields are marked with aria-required
- [ ] Error messages use role="alert"
- [ ] Errors have aria-describedby linking to error text
- [ ] Form has clear heading structure
- [ ] Keyboard navigation works completely
- [ ] Focus is managed appropriately
- [ ] Color is not the only indicator of errors
- [ ] Contrast ratios meet WCAG AA standards
- [ ] Screen reader testing completed
---
**Resources**:
- WCAG Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
- React Hook Form a11y: https://react-hook-form.com/advanced-usage#AccessibilityA11y

View File

@@ -0,0 +1,255 @@
# Error Handling Guide
Complete guide for handling and displaying form errors.
---
## Error Display Patterns
### 1. Inline Errors (Recommended)
```typescript
<input {...register('email')} />
{errors.email && (
<span role="alert" className="text-red-600">
{errors.email.message}
</span>
)}
```
### 2. Error Summary (Accessibility Best Practice)
```typescript
{Object.keys(errors).length > 0 && (
<div role="alert" aria-live="assertive" className="error-summary">
<h3>Please fix the following errors:</h3>
<ul>
{Object.entries(errors).map(([field, error]) => (
<li key={field}>
<strong>{field}:</strong> {error.message}
</li>
))}
</ul>
</div>
)}
```
### 3. Toast Notifications
```typescript
const onError = (errors) => {
toast.error(`Please fix ${Object.keys(errors).length} errors`)
}
<form onSubmit={handleSubmit(onSubmit, onError)}>
```
---
## ARIA Attributes
### Required Attributes
```typescript
<input
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
aria-required="true"
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
)}
```
---
## Custom Error Messages
### Method 1: In Zod Schema
```typescript
const schema = z.object({
email: z.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
password: z.string()
.min(8, { message: 'Password must be at least 8 characters long' }),
})
```
### Method 2: Custom Error Map
```typescript
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
switch (issue.code) {
case z.ZodIssueCode.too_small:
return { message: `Must be at least ${issue.minimum} characters` }
case z.ZodIssueCode.invalid_string:
if (issue.validation === 'email') {
return { message: 'Please enter a valid email address' }
}
break
default:
return { message: ctx.defaultError }
}
}
z.setErrorMap(customErrorMap)
```
---
## Error Formatting
### Flatten Errors for Forms
```typescript
try {
schema.parse(data)
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors = error.flatten().fieldErrors
// Result: { email: ['Invalid email'], password: ['Too short'] }
}
}
```
### Format Errors for Display
```typescript
const formatError = (error: FieldError): string => {
switch (error.type) {
case 'required':
return 'This field is required'
case 'min':
return `Minimum length is ${error.message}`
case 'pattern':
return 'Invalid format'
default:
return error.message || 'Invalid value'
}
}
```
---
## Server Error Integration
```typescript
const onSubmit = async (data) => {
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
})
const result = await response.json()
if (!result.success && result.errors) {
// Map server errors to form fields
Object.entries(result.errors).forEach(([field, message]) => {
setError(field, {
type: 'server',
message: Array.isArray(message) ? message[0] : message,
})
})
}
} catch (error) {
// Network error
setError('root', {
type: 'server',
message: 'Unable to connect. Please try again.',
})
}
}
```
---
## Error Persistence
### Clear Errors on Input Change
```typescript
<input
{...register('email')}
onChange={(e) => {
register('email').onChange(e)
clearErrors('email') // Clear error when user starts typing
}}
/>
```
### Clear All Errors on Submit Success
```typescript
const onSubmit = async (data) => {
const success = await submitData(data)
if (success) {
reset() // Clears form and errors
}
}
```
---
## Internationalization (i18n)
```typescript
import { useTranslation } from 'react-i18next'
const { t } = useTranslation()
const schema = z.object({
email: z.string().email(t('errors.invalidEmail')),
password: z.string().min(8, t('errors.passwordTooShort')),
})
```
---
## Error Components
### Reusable Error Display
```typescript
function FormError({ error }: { error?: FieldError }) {
if (!error) return null
return (
<div role="alert" className="error">
<svg className="icon">...</svg>
<span>{error.message}</span>
</div>
)
}
// Usage
<FormError error={errors.email} />
```
### Field Group with Error
```typescript
function FieldGroup({ name, label, type = 'text', register, errors }) {
return (
<div className="field-group">
<label htmlFor={name}>{label}</label>
<input
id={name}
type={type}
{...register(name)}
aria-invalid={errors[name] ? 'true' : 'false'}
/>
{errors[name] && <FormError error={errors[name]} />}
</div>
)
}
```
---
**Official Docs**: https://react-hook-form.com/

View File

@@ -0,0 +1,197 @@
# Links to Official Documentation
Organized links to official documentation and resources.
---
## React Hook Form
### Core Documentation
- **Main Site**: https://react-hook-form.com/
- **Get Started**: https://react-hook-form.com/get-started
- **API Reference**: https://react-hook-form.com/api
- **TS Support**: https://react-hook-form.com/ts
### Hooks
- **useForm**: https://react-hook-form.com/api/useform
- **useController**: https://react-hook-form.com/api/usecontroller
- **useFieldArray**: https://react-hook-form.com/api/usefieldarray
- **useWatch**: https://react-hook-form.com/api/usewatch
- **useFormContext**: https://react-hook-form.com/api/useformcontext
- **useFormState**: https://react-hook-form.com/api/useformstate
- **Controller**: https://react-hook-form.com/api/controller
### Advanced Usage
- **Smart Form Component**: https://react-hook-form.com/advanced-usage#SmartFormComponent
- **Error Messages**: https://react-hook-form.com/advanced-usage#ErrorMessages
- **Accessibility**: https://react-hook-form.com/advanced-usage#AccessibilityA11y
- **Performance**: https://react-hook-form.com/advanced-usage#PerformanceOptimization
- **Schema Validation**: https://react-hook-form.com/advanced-usage#SchemaValidation
### Examples
- **Examples Library**: https://react-hook-form.com/form-builder
- **CodeSandbox Examples**: https://codesandbox.io/examples/package/react-hook-form
---
## Zod
### Core Documentation
- **Main Site**: https://zod.dev/
- **Installation**: https://zod.dev/#installation
- **Basic Usage**: https://zod.dev/basics
- **Primitives**: https://zod.dev/primitives
- **Coercion**: https://zod.dev/coercion
### Schema Types
- **Objects**: https://zod.dev/objects
- **Arrays**: https://zod.dev/arrays
- **Unions**: https://zod.dev/unions
- **Records**: https://zod.dev/records
- **Maps**: https://zod.dev/maps
- **Sets**: https://zod.dev/sets
- **Promises**: https://zod.dev/promises
### Validation
- **Refinements**: https://zod.dev/refinements
- **Transforms**: https://zod.dev/transforms
- **Preprocessing**: https://zod.dev/preprocessing
- **Pipes**: https://zod.dev/pipes
### Error Handling
- **Error Handling**: https://zod.dev/error-handling
- **Custom Error Messages**: https://zod.dev/error-handling#custom-error-messages
- **Error Formatting**: https://zod.dev/error-handling#formatting
### TypeScript
- **Type Inference**: https://zod.dev/type-inference
- **Type Helpers**: https://zod.dev/type-inference#type-helpers
---
## @hookform/resolvers
### Documentation
- **Main Docs**: https://github.com/react-hook-form/resolvers
- **zodResolver**: https://github.com/react-hook-form/resolvers#zod
- **All Resolvers**: https://github.com/react-hook-form/resolvers#api
### Installation
```bash
npm install @hookform/resolvers
```
---
## shadcn/ui
### Form Components
- **Form Component**: https://ui.shadcn.com/docs/components/form
- **Input**: https://ui.shadcn.com/docs/components/input
- **Textarea**: https://ui.shadcn.com/docs/components/textarea
- **Select**: https://ui.shadcn.com/docs/components/select
- **Checkbox**: https://ui.shadcn.com/docs/components/checkbox
- **Radio Group**: https://ui.shadcn.com/docs/components/radio-group
- **Switch**: https://ui.shadcn.com/docs/components/switch
- **Button**: https://ui.shadcn.com/docs/components/button
### Installation
- **Vite Setup**: https://ui.shadcn.com/docs/installation/vite
- **Next.js Setup**: https://ui.shadcn.com/docs/installation/next
- **CLI**: https://ui.shadcn.com/docs/cli
---
## TypeScript
### Documentation
- **Handbook**: https://www.typescriptlang.org/docs/handbook/intro.html
- **Type Inference**: https://www.typescriptlang.org/docs/handbook/type-inference.html
- **Generics**: https://www.typescriptlang.org/docs/handbook/2/generics.html
---
## Accessibility (WCAG)
### Guidelines
- **WCAG 2.1**: https://www.w3.org/WAI/WCAG21/quickref/
- **ARIA Authoring Practices**: https://www.w3.org/WAI/ARIA/apg/
- **Forms Best Practices**: https://www.w3.org/WAI/tutorials/forms/
---
## Community Resources
### React Hook Form
- **GitHub**: https://github.com/react-hook-form/react-hook-form
- **Discord**: https://discord.gg/yYv7GZ8
- **Stack Overflow**: https://stackoverflow.com/questions/tagged/react-hook-form
### Zod
- **GitHub**: https://github.com/colinhacks/zod
- **Discord**: https://discord.gg/RcG33DQJdf
- **Stack Overflow**: https://stackoverflow.com/questions/tagged/zod
---
## Video Tutorials
### React Hook Form
- **Official YouTube**: https://www.youtube.com/@bluebill1049
- **Traversy Media**: https://www.youtube.com/watch?v=bU_eq8qyjic
- **Web Dev Simplified**: https://www.youtube.com/watch?v=cc_xmawJ8Kg
### Zod
- **Matt Pocock**: https://www.youtube.com/watch?v=L6BE-U3oy80
- **Theo**: https://www.youtube.com/watch?v=AeQ3f4zmSMs
---
## Blog Posts & Articles
### React Hook Form
- **React Hook Form Best Practices**: https://react-hook-form.com/faqs
- **Performance Comparison**: https://react-hook-form.com/faqs#PerformanceofReactHookForm
### Zod
- **Total TypeScript**: https://www.totaltypescript.com/tutorials/zod
- **Zod Tutorial**: https://zod.dev/tutorials
---
## Package Managers
### npm
```bash
npm install react-hook-form zod @hookform/resolvers
```
### pnpm
```bash
pnpm add react-hook-form zod @hookform/resolvers
```
### yarn
```bash
yarn add react-hook-form zod @hookform/resolvers
```
---
## Version Information
**Latest Tested Versions** (as of 2025-10-23):
- react-hook-form: 7.65.0
- zod: 4.1.12
- @hookform/resolvers: 5.2.2
**Check for updates**:
```bash
npm view react-hook-form version
npm view zod version
npm view @hookform/resolvers version
```
---
**Last Updated**: 2025-10-23

View File

@@ -0,0 +1,355 @@
# Performance Optimization Guide
Strategies for optimizing React Hook Form performance.
---
## Form Validation Modes
### onSubmit (Best Performance)
```typescript
const form = useForm({
mode: 'onSubmit', // Validate only on submit
resolver: zodResolver(schema),
})
```
**Pros**: Minimal re-renders, best performance
**Cons**: No live feedback
### onBlur (Good Balance)
```typescript
const form = useForm({
mode: 'onBlur', // Validate when field loses focus
resolver: zodResolver(schema),
})
```
**Pros**: Good UX, reasonable performance
**Cons**: Some re-renders on blur
### onChange (Live Feedback)
```typescript
const form = useForm({
mode: 'onChange', // Validate on every change
resolver: zodResolver(schema),
})
```
**Pros**: Immediate feedback
**Cons**: Most re-renders, can be slow with complex validation
### all (Maximum Validation)
```typescript
const form = useForm({
mode: 'all', // Validate on blur, change, and submit
resolver: zodResolver(schema),
})
```
**Pros**: Most responsive
**Cons**: Highest performance cost
---
## Controlled vs Uncontrolled
### Uncontrolled (Faster)
```typescript
// Best performance - no React state
<input {...register('email')} />
```
### Controlled (More Control)
```typescript
// More React state = more re-renders
<Controller
control={control}
name="email"
render={({ field }) => <Input {...field} />}
/>
```
**Rule**: Use `register` by default, `Controller` only when necessary.
---
## watch() Optimization
### Watch Specific Fields
```typescript
// BAD - Watches all fields, re-renders on any change
const values = watch()
// GOOD - Watch only what you need
const email = watch('email')
const [email, password] = watch(['email', 'password'])
```
### useWatch for Isolation
```typescript
import { useWatch } from 'react-hook-form'
// Isolated component - only re-renders when email changes
function EmailDisplay() {
const email = useWatch({ control, name: 'email' })
return <div>{email}</div>
}
```
---
## Debouncing Validation
### Manual Debounce
```typescript
import { useDebouncedCallback } from 'use-debounce'
const debouncedValidation = useDebouncedCallback(
() => trigger('username'),
500 // Wait 500ms
)
<input
{...register('username')}
onChange={(e) => {
register('username').onChange(e)
debouncedValidation()
}}
/>
```
---
## shouldUnregister Flag
### Keep Data When Unmounting
```typescript
const form = useForm({
shouldUnregister: false, // Keep field data when unmounted
})
```
**Use When**:
- Multi-step forms
- Tabbed interfaces
- Conditional fields that should persist
### Clear Data When Unmounting
```typescript
const form = useForm({
shouldUnregister: true, // Remove field data when unmounted
})
```
**Use When**:
- Truly conditional fields
- Dynamic forms
- Want to clear data automatically
---
## useFieldArray Optimization
### Use field.id as Key
```typescript
// CRITICAL for performance
{fields.map((field) => (
<div key={field.id}> {/* Not index! */}
...
</div>
))}
```
### Avoid Unnecessary Re-renders
```typescript
// Extract field components
const FieldItem = React.memo(({ field, index, register, remove }) => (
<div>
<input {...register(`items.${index}.name`)} />
<button onClick={() => remove(index)}>Remove</button>
</div>
))
// Use memoized component
{fields.map((field, index) => (
<FieldItem
key={field.id}
field={field}
index={index}
register={register}
remove={remove}
/>
))}
```
---
## formState Optimization
### Subscribe to Specific Properties
```typescript
// BAD - Subscribes to all formState changes
const { formState } = useForm()
// GOOD - Subscribe only to what you need
const { isDirty, isValid } = useForm().formState
// BETTER - Use useFormState for isolation
import { useFormState } from 'react-hook-form'
const { isDirty } = useFormState({ control })
```
---
## Resolver Optimization
### Memoize Schema
```typescript
// BAD - New schema on every render
const form = useForm({
resolver: zodResolver(z.object({ email: z.string() })),
})
// GOOD - Schema defined outside component
const schema = z.object({ email: z.string() })
function Form() {
const form = useForm({
resolver: zodResolver(schema),
})
}
```
---
## Large Forms
### Split into Sections
```typescript
function PersonalInfoSection() {
const { register } = useFormContext()
return (
<div>
<input {...register('firstName')} />
<input {...register('lastName')} />
</div>
)
}
function ContactInfoSection() {
const { register } = useFormContext()
return (
<div>
<input {...register('email')} />
<input {...register('phone')} />
</div>
)
}
function LargeForm() {
const methods = useForm()
return (
<FormProvider {...methods}>
<form>
<PersonalInfoSection />
<ContactInfoSection />
</form>
</FormProvider>
)
}
```
### Virtualize Long Lists
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualizedFieldArray() {
const { fields } = useFieldArray({ control, name: 'items' })
const parentRef = React.useRef(null)
const rowVirtualizer = useVirtualizer({
count: fields.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
})
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const field = fields[virtualRow.index]
return (
<div key={field.id}>
<input {...register(`items.${virtualRow.index}.name`)} />
</div>
)
})}
</div>
</div>
)
}
```
---
## Performance Benchmarks
| Optimization | Before | After | Improvement |
|--------------|--------|-------|-------------|
| mode: onSubmit vs onChange | 100ms | 20ms | 80% |
| watch() all vs watch('field') | 50ms | 10ms | 80% |
| field.id vs index key | 200ms | 50ms | 75% |
| Memoized schema | 30ms | 5ms | 83% |
---
## Profiling
### React DevTools Profiler
1. Open React DevTools
2. Go to Profiler tab
3. Click Record
4. Interact with form
5. Stop recording
6. Analyze render times
### Performance.mark API
```typescript
const onSubmit = (data) => {
performance.mark('form-submit-start')
// Submit logic
performance.mark('form-submit-end')
performance.measure('form-submit', 'form-submit-start', 'form-submit-end')
const measures = performance.getEntriesByName('form-submit')
console.log('Submit time:', measures[0].duration, 'ms')
}
```
---
**Official Docs**: https://react-hook-form.com/advanced-usage#PerformanceOptimization

View File

@@ -0,0 +1,420 @@
# React Hook Form API Reference
Complete API reference for React Hook Form v7.65.0
---
## useForm Hook
```typescript
const {
register,
handleSubmit,
watch,
formState,
setValue,
getValues,
reset,
trigger,
control,
setError,
clearErrors,
setFocus,
} = useForm<FormData>(options)
```
### Options
| Option | Type | Description |
|--------|------|-------------|
| `resolver` | `Resolver` | Schema validation resolver (zodResolver, etc.) |
| `mode` | `'onSubmit' \| 'onChange' \| 'onBlur' \| 'all'` | When to validate (default: 'onSubmit') |
| `reValidateMode` | `'onChange' \| 'onBlur'` | When to re-validate after error |
| `defaultValues` | `object \| () => object \| Promise<object>` | Initial form values |
| `values` | `object` | Controlled form values |
| `resetOptions` | `object` | Options for reset behavior |
| `shouldUnregister` | `boolean` | Unregister fields when unmounted |
| `shouldFocusError` | `boolean` | Focus first error on submit |
| `criteriaMode` | `'firstError' \| 'all'` | Return first error or all |
| `delayError` | `number` | Delay error display (ms) |
---
## register
Register input and apply validation rules.
```typescript
<input {...register('fieldName', options)} />
```
**Options**:
- `required`: `boolean | string`
- `min`: `number | { value: number, message: string }`
- `max`: `number | { value: number, message: string }`
- `minLength`: `number | { value: number, message: string }`
- `maxLength`: `number | { value: number, message: string }`
- `pattern`: `RegExp | { value: RegExp, message: string }`
- `validate`: `(value) => boolean | string | object`
- `valueAsNumber`: `boolean`
- `valueAsDate`: `boolean`
- `disabled`: `boolean`
- `onChange`: `(e) => void`
- `onBlur`: `(e) => void`
---
## handleSubmit
Wraps your form submission handler.
```typescript
<form onSubmit={handleSubmit(onSubmit, onError)}>
function onSubmit(data: FormData) {
// Valid data
}
function onError(errors: FieldErrors) {
// Validation errors
}
```
---
## watch
Watch specified inputs and return their values.
```typescript
// Watch all fields
const values = watch()
// Watch specific field
const email = watch('email')
// Watch multiple fields
const [email, password] = watch(['email', 'password'])
// Watch with callback
useEffect(() => {
const subscription = watch((value, { name, type }) => {
console.log(value, name, type)
})
return () => subscription.unsubscribe()
}, [watch])
```
---
## formState
Form state object.
```typescript
const {
isDirty, // Form has been modified
dirtyFields, // Object of modified fields
touchedFields, // Object of touched fields
isSubmitted, // Form has been submitted
isSubmitSuccessful, // Last submission successful
isSubmitting, // Form is currently submitting
isValidating, // Form is validating
isValid, // Form is valid
errors, // Validation errors
submitCount, // Number of submissions
} = formState
```
---
## setValue
Set field value programmatically.
```typescript
setValue('fieldName', value, options)
// Options
{
shouldValidate: boolean, // Trigger validation
shouldDirty: boolean, // Mark as dirty
shouldTouch: boolean, // Mark as touched
}
```
---
## getValues
Get current form values.
```typescript
// Get all values
const values = getValues()
// Get specific field
const email = getValues('email')
// Get multiple fields
const [email, password] = getValues(['email', 'password'])
```
---
## reset
Reset form to default values.
```typescript
reset() // Reset to defaultValues
reset({ email: '', password: '' }) // Reset to specific values
reset(undefined, {
keepErrors: boolean,
keepDirty: boolean,
keepIsSubmitted: boolean,
keepTouched: boolean,
keepIsValid: boolean,
keepSubmitCount: boolean,
})
```
---
## trigger
Manually trigger validation.
```typescript
// Trigger all fields
await trigger()
// Trigger specific field
await trigger('email')
// Trigger multiple fields
await trigger(['email', 'password'])
```
---
## setError
Set field error manually.
```typescript
setError('fieldName', {
type: 'manual',
message: 'Error message',
})
// Root error (not tied to specific field)
setError('root', {
type: 'server',
message: 'Server error',
})
```
---
## clearErrors
Clear field errors.
```typescript
clearErrors() // Clear all errors
clearErrors('email') // Clear specific field
clearErrors(['email', 'password']) // Clear multiple fields
```
---
## setFocus
Focus on specific field.
```typescript
setFocus('fieldName', { shouldSelect: true })
```
---
## Controller
For controlled components (third-party UI libraries).
```typescript
import { Controller } from 'react-hook-form'
<Controller
name="fieldName"
control={control}
defaultValue=""
rules={{ required: true }}
render={({ field, fieldState, formState }) => (
<CustomInput
{...field}
error={fieldState.error}
/>
)}
/>
```
**render props**:
- `field`: `{ value, onChange, onBlur, ref, name }`
- `fieldState`: `{ invalid, isTouched, isDirty, error }`
- `formState`: Full form state
---
## useController
Hook version of Controller (for reusable components).
```typescript
import { useController } from 'react-hook-form'
function CustomInput({ name, control }) {
const {
field,
fieldState: { invalid, isTouched, isDirty, error },
formState: { touchedFields, dirtyFields }
} = useController({
name,
control,
rules: { required: true },
defaultValue: '',
})
return <input {...field} />
}
```
---
## useFieldArray
Manage dynamic field arrays.
```typescript
import { useFieldArray } from 'react-hook-form'
const { fields, append, prepend, remove, insert, update, replace } = useFieldArray({
control,
name: 'items',
keyName: 'id', // Default: 'id'
})
```
**Methods**:
- `append(value)` - Add to end
- `prepend(value)` - Add to beginning
- `insert(index, value)` - Insert at index
- `remove(index)` - Remove at index
- `update(index, value)` - Update at index
- `replace(values)` - Replace entire array
**Important**: Use `field.id` as key, not array index!
```typescript
{fields.map((field, index) => (
<div key={field.id}> {/* Use field.id! */}
<input {...register(`items.${index}.name`)} />
</div>
))}
```
---
## useWatch
Subscribe to input changes without re-rendering entire form.
```typescript
import { useWatch } from 'react-hook-form'
const email = useWatch({
control,
name: 'email',
defaultValue: '',
})
```
---
## useFormState
Subscribe to form state without re-rendering entire form.
```typescript
import { useFormState } from 'react-hook-form'
const { isDirty, isValid } = useFormState({ control })
```
---
## useFormContext
Access form context (for deeply nested components).
```typescript
import { useFormContext } from 'react-hook-form'
function NestedComponent() {
const { register, formState: { errors } } = useFormContext()
return <input {...register('email')} />
}
// Wrap form with FormProvider
import { FormProvider, useForm } from 'react-hook-form'
function App() {
const methods = useForm()
return (
<FormProvider {...methods}>
<form>
<NestedComponent />
</form>
</FormProvider>
)
}
```
---
## ErrorMessage
Helper component for displaying errors (from @hookform/error-message).
```typescript
import { ErrorMessage } from '@hookform/error-message'
<ErrorMessage
errors={errors}
name="email"
render={({ message }) => <span className="error">{message}</span>}
/>
```
---
## DevTool
Development tool for debugging (from @hookform/devtools).
```typescript
import { DevTool } from '@hookform/devtools'
<DevTool control={control} />
```
---
**Official Docs**: https://react-hook-form.com/

View File

@@ -0,0 +1,390 @@
# shadcn/ui Integration Guide
Complete guide for using shadcn/ui with React Hook Form + Zod.
---
## Form Component (Legacy)
**Status**: "Not actively developed" according to shadcn/ui documentation
**Recommendation**: Use Field component for new projects (coming soon)
### Installation
```bash
npx shadcn@latest add form
```
### Basic Usage
```typescript
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
const schema = z.object({
username: z.string().min(2),
})
function ProfileForm() {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { username: '' },
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
```
---
## Form Component Anatomy
### FormField
```typescript
<FormField
control={form.control} // Required
name="fieldName" // Required
render={({ field, fieldState, formState }) => (
// Your field component
)}
/>
```
### FormItem
Container for field, label, description, and message.
```typescript
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Helper text</FormDescription>
<FormMessage />
</FormItem>
```
### FormControl
Wraps the actual input component.
```typescript
<FormControl>
<Input {...field} />
</FormControl>
```
### FormLabel
Accessible label with automatic linking to input.
```typescript
<FormLabel>Email Address</FormLabel>
```
### FormDescription
Helper text for the field.
```typescript
<FormDescription>
We'll never share your email.
</FormDescription>
```
### FormMessage
Displays validation errors.
```typescript
<FormMessage />
```
---
## Common Patterns
### Input Field
```typescript
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
```
### Textarea
```typescript
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
```
### Select
```typescript
<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="user">User</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
```
### Checkbox
```typescript
<FormField
control={form.control}
name="newsletter"
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>Subscribe to newsletter</FormLabel>
<FormDescription>
Receive email updates about new products.
</FormDescription>
</div>
</FormItem>
)}
/>
```
### Radio Group
```typescript
<FormField
control={form.control}
name="plan"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Select a plan</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="free" />
</FormControl>
<FormLabel className="font-normal">Free</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="pro" />
</FormControl>
<FormLabel className="font-normal">Pro</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
```
### Switch
```typescript
<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 your account activity.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
```
---
## Nested Objects
```typescript
const schema = z.object({
user: z.object({
name: z.string(),
email: z.string().email(),
}),
})
<FormField
control={form.control}
name="user.name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
```
---
## Arrays
```typescript
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
})
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`items.${index}.name`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
```
---
## Custom Validation
```typescript
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{/* Custom error styling */}
{errors.username && (
<div className="text-sm font-medium text-destructive">
{errors.username.message}
</div>
)}
</FormItem>
)}
/>
```
---
## Field Component (Future)
**Status**: Recommended for new implementations (in development)
Check official docs for latest: https://ui.shadcn.com/docs/components/form
---
## Tips
1. **Always spread {...field}** in FormControl
2. **Use Form component** for automatic ID generation
3. **FormMessage** automatically displays errors
4. **Combine with Zod** for type-safe validation
5. **Check documentation** - Form component is not actively developed
---
**Official Docs**:
- shadcn/ui Form: https://ui.shadcn.com/docs/components/form
- React Hook Form: https://react-hook-form.com/

381
references/top-errors.md Normal file
View File

@@ -0,0 +1,381 @@
# Top 12 Common Errors with Solutions
Complete reference for known issues and their solutions.
---
## 1. Zod v4 Type Inference Errors
**Error**: Type inference doesn't work correctly with Zod v4
**Symptoms**:
```typescript
// Types don't match expected structure
const schema = z.object({ name: z.string() })
type FormData = z.infer<typeof schema> // Type issues
```
**Source**: [GitHub Issue #13109](https://github.com/react-hook-form/react-hook-form/issues/13109) (Closed 2025-11-01)
**Note**: This issue was resolved in react-hook-form v7.66.x. Upgrade to v7.66.1+ to avoid this problem.
**Solution**:
```typescript
// Use correct Zod v4 patterns
const schema = z.object({ name: z.string() })
type FormData = z.infer<typeof schema>
// Explicitly type useForm if needed
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
```
---
## 2. Uncontrolled to Controlled Warning
**Error**: "A component is changing an uncontrolled input to be controlled"
**Symptoms**:
```
Warning: A component is changing an uncontrolled input of type text to be controlled.
Input elements should not switch from uncontrolled to controlled (or vice versa).
```
**Cause**: Not setting defaultValues causes fields to be undefined initially
**Solution**:
```typescript
// BAD
const form = useForm()
// GOOD - Always set defaultValues
const form = useForm({
defaultValues: {
email: '',
password: '',
remember: false,
},
})
```
---
## 3. Nested Object Validation Errors
**Error**: Errors for nested fields don't display correctly
**Symptoms**:
```typescript
// errors.address.street is undefined even though validation failed
<span>{errors.address.street?.message}</span> // Shows nothing
```
**Solution**:
```typescript
// Use optional chaining for nested errors
{errors.address?.street && (
<span>{errors.address.street.message}</span>
)}
// OR check if errors.address exists first
{errors.address && errors.address.street && (
<span>{errors.address.street.message}</span>
)}
```
---
## 4. Array Field Re-renders
**Error**: Form re-renders excessively with useFieldArray
**Cause**: Using array index as key instead of field.id
**Solution**:
```typescript
// BAD
{fields.map((field, index) => (
<div key={index}> {/* Using index causes re-renders */}
...
</div>
))}
// GOOD
{fields.map((field) => (
<div key={field.id}> {/* Use field.id */}
...
</div>
))}
```
---
## 5. Async Validation Race Conditions
**Error**: Multiple validation requests cause conflicting results
**Symptoms**: Old validation results override new ones
**Solution**:
```typescript
// Use debouncing
import { useDebouncedCallback } from 'use-debounce'
const debouncedValidation = useDebouncedCallback(
() => trigger('username'),
500 // Wait 500ms after user stops typing
)
// AND cancel pending requests
const abortControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
abortControllerRef.current = new AbortController()
// Make request with abort signal
fetch('/api/check', { signal: abortControllerRef.current.signal })
}, [value])
```
---
## 6. Server Error Mapping
**Error**: Server validation errors don't map to form fields
**Solution**:
```typescript
const onSubmit = async (data) => {
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
})
if (!response.ok) {
const { errors } = await response.json()
// Map server errors to form fields
Object.entries(errors).forEach(([field, message]) => {
setError(field, {
type: 'server',
message: Array.isArray(message) ? message[0] : message,
})
})
return
}
} catch (error) {
setError('root', {
type: 'server',
message: 'Network error',
})
}
}
```
---
## 7. Default Values Not Applied
**Error**: Form fields don't show default values
**Cause**: Setting defaultValues after form initialization
**Solution**:
```typescript
// BAD - Set in useState
const [defaultValues, setDefaultValues] = useState({})
useEffect(() => {
setDefaultValues({ email: 'user@example.com' }) // Too late!
}, [])
const form = useForm({ defaultValues })
// GOOD - Set directly or use reset()
const form = useForm({
defaultValues: { email: 'user@example.com' },
})
// OR fetch and use reset
useEffect(() => {
async function loadData() {
const data = await fetchData()
reset(data)
}
loadData()
}, [reset])
```
---
## 8. Controller Field Not Updating
**Error**: Custom component doesn't update when value changes
**Cause**: Not spreading {...field} in Controller render
**Solution**:
```typescript
// BAD
<Controller
render={({ field }) => (
<CustomInput value={field.value} onChange={field.onChange} />
)}
/>
// GOOD - Spread all field props
<Controller
render={({ field }) => (
<CustomInput {...field} />
)}
/>
```
---
## 9. useFieldArray Key Warnings
**Error**: React warning about duplicate keys in list
**Symptoms**:
```
Warning: Encountered two children with the same key
```
**Solution**:
```typescript
// BAD - Using index as key
{fields.map((field, index) => (
<div key={index}>...</div>
))}
// GOOD - Use field.id
{fields.map((field) => (
<div key={field.id}>...</div>
))}
```
---
## 10. Schema Refinement Error Paths
**Error**: Custom validation errors appear at wrong field
**Cause**: Not specifying path in refinement
**Solution**:
```typescript
// BAD - Error appears at form level
z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
// Missing path!
})
// GOOD - Error appears at confirmPassword field
z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'], // Specify path
})
```
---
## 11. Transform vs Preprocess Confusion
**Error**: Data transformation doesn't work as expected
**When to use each**:
```typescript
// Use TRANSFORM for output transformation (after validation)
z.string().transform((val) => val.toUpperCase())
// Input: 'hello' -> Validation: passes -> Output: 'HELLO'
// Use PREPROCESS for input transformation (before validation)
z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().optional()
)
// Input: '' -> Preprocess: undefined -> Validation: passes
```
---
## 12. Multiple Resolver Conflicts
**Error**: Form validation doesn't work with multiple resolvers
**Cause**: Trying to use multiple validation libraries simultaneously
**Solution**:
```typescript
// BAD - Can't use multiple resolvers
const form = useForm({
resolver: zodResolver(schema),
resolver: yupResolver(schema), // Overrides previous
})
// GOOD - Use single resolver, combine schemas if needed
const schema1 = z.object({ email: z.string() })
const schema2 = z.object({ password: z.string() })
const combinedSchema = schema1.merge(schema2)
const form = useForm({
resolver: zodResolver(combinedSchema),
})
```
---
## Debugging Tips
### Enable DevTools
```bash
npm install @hookform/devtools
```
```typescript
import { DevTool } from '@hookform/devtools'
<DevTool control={control} />
```
### Log Form State
```typescript
useEffect(() => {
console.log('Form State:', formState)
console.log('Errors:', errors)
console.log('Values:', getValues())
}, [formState, errors, getValues])
```
### Validate on Change During Development
```typescript
const form = useForm({
mode: 'onChange', // See errors immediately
resolver: zodResolver(schema),
})
```
---
**Official Docs**:
- React Hook Form: https://react-hook-form.com/
- Zod: https://zod.dev/

View File

@@ -0,0 +1,396 @@
# Comprehensive Zod Schemas Guide
Complete reference for all Zod schema types and patterns.
---
## Primitives
```typescript
// String
z.string()
z.string().min(3, "Min 3 characters")
z.string().max(100, "Max 100 characters")
z.string().length(10, "Exactly 10 characters")
z.string().email("Invalid email")
z.string().url("Invalid URL")
z.string().uuid("Invalid UUID")
z.string().regex(/pattern/, "Does not match pattern")
z.string().trim() // Trim whitespace
z.string().toLowerCase() //Convert to lowercase
z.string().toUpperCase() // Convert to uppercase
// Number
z.number()
z.number().int("Must be integer")
z.number().positive("Must be positive")
z.number().negative("Must be negative")
z.number().min(0, "Min is 0")
z.number().max(100, "Max is 100")
z.number().multipleOf(5, "Must be multiple of 5")
z.number().finite() // No Infinity or NaN
z.number().safe() // Within JS safe integer range
// Boolean
z.boolean()
// Date
z.date()
z.date().min(new Date("2020-01-01"), "Too old")
z.date().max(new Date(), "Cannot be in future")
// BigInt
z.bigint()
```
---
## Objects
```typescript
// Basic object
const userSchema = z.object({
name: z.string(),
age: z.number(),
})
// Nested object
const profileSchema = z.object({
user: userSchema,
address: z.object({
street: z.string(),
city: z.string(),
}),
})
// Partial (all fields optional)
const partialUserSchema = userSchema.partial()
// Deep Partial (recursively optional)
const deepPartialSchema = profileSchema.deepPartial()
// Pick specific fields
const nameOnlySchema = userSchema.pick({ name: true })
// Omit specific fields
const withoutAgeSchema = userSchema.omit({ age: true })
// Merge objects
const extendedUserSchema = userSchema.merge(z.object({
email: z.string().email(),
}))
// Passthrough (allow extra fields)
const passthroughSchema = userSchema.passthrough()
// Strict (no extra fields)
const strictSchema = userSchema.strict()
// Catchall (type for extra fields)
const catchallSchema = userSchema.catchall(z.string())
```
---
## Arrays
```typescript
// Array of strings
z.array(z.string())
// With length constraints
z.array(z.string()).min(1, "At least one item required")
z.array(z.string()).max(10, "Max 10 items")
z.array(z.string()).length(5, "Exactly 5 items")
z.array(z.string()).nonempty("Array cannot be empty")
// Array of objects
z.array(z.object({
name: z.string(),
age: z.number(),
}))
```
---
## Tuples
```typescript
// Fixed-length array with specific types
z.tuple([z.string(), z.number(), z.boolean()])
// With rest
z.tuple([z.string(), z.number()]).rest(z.boolean())
```
---
## Enums and Literals
```typescript
// Enum
z.enum(['red', 'green', 'blue'])
// Native enum
enum Color { Red, Green, Blue }
z.nativeEnum(Color)
// Literal
z.literal('hello')
z.literal(42)
z.literal(true)
```
---
## Unions and Discriminated Unions
```typescript
// Union
z.union([z.string(), z.number()])
// Discriminated union (recommended for better errors)
z.discriminatedUnion('type', [
z.object({ type: z.literal('user'), name: z.string() }),
z.object({ type: z.literal('admin'), permissions: z.array(z.string()) }),
])
```
---
## Optional and Nullable
```typescript
// Optional (value | undefined)
z.string().optional()
z.optional(z.string()) // Same as above
// Nullable (value | null)
z.string().nullable()
z.nullable(z.string()) // Same as above
// Nullish (value | null | undefined)
z.string().nullish()
```
---
## Default Values
```typescript
z.string().default('default value')
z.number().default(0)
z.boolean().default(false)
z.array(z.string()).default([])
```
---
## Refinements (Custom Validation)
```typescript
// Basic refinement
z.string().refine((val) => val.length > 5, {
message: "String must be longer than 5 characters",
})
// With custom path
z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
// Multiple refinements
z.string()
.refine((val) => val.length >= 8, "Min 8 characters")
.refine((val) => /[A-Z]/.test(val), "Must contain uppercase")
.refine((val) => /[0-9]/.test(val), "Must contain number")
// Async refinement
z.string().refine(async (val) => {
const available = await checkAvailability(val)
return available
}, "Already taken")
```
---
## Transforms
```typescript
// String to number
z.string().transform((val) => parseInt(val, 10))
// Trim whitespace
z.string().transform((val) => val.trim())
// Parse date
z.string().transform((val) => new Date(val))
// Chain transform and refine
z.string()
.transform((val) => parseInt(val, 10))
.refine((val) => !isNaN(val), "Must be a number")
```
---
## Preprocess
```typescript
// Process before validation
z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().optional()
)
// Convert to number
z.preprocess(
(val) => Number(val),
z.number()
)
```
---
## Intersections
```typescript
const baseUser = z.object({ name: z.string() })
const withEmail = z.object({ email: z.string().email() })
// Intersection (combines both)
const userWithEmail = baseUser.and(withEmail)
// OR
const userWithEmail = z.intersection(baseUser, withEmail)
```
---
## Records and Maps
```typescript
// Record (object with dynamic keys)
z.record(z.string()) // { [key: string]: string }
z.record(z.string(), z.number()) // { [key: string]: number }
// Map
z.map(z.string(), z.number())
```
---
## Sets
```typescript
z.set(z.string())
z.set(z.number()).min(1, "At least one item")
z.set(z.string()).max(10, "Max 10 items")
```
---
## Promises
```typescript
z.promise(z.string())
z.promise(z.object({ data: z.string() }))
```
---
## Custom Error Messages
```typescript
// Field-level
z.string({ required_error: "Name is required" })
z.number({ invalid_type_error: "Must be a number" })
// Validation-level
z.string().min(3, { message: "Min 3 characters" })
z.string().email({ message: "Invalid email format" })
// Custom error map
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === "string") {
return { message: "Please enter text" }
}
}
return { message: ctx.defaultError }
}
z.setErrorMap(customErrorMap)
```
---
## Type Inference
```typescript
const userSchema = z.object({
name: z.string(),
age: z.number(),
})
// Infer TypeScript type
type User = z.infer<typeof userSchema>
// Result: { name: string; age: number }
// Input type (before transforms)
type UserInput = z.input<typeof transformSchema>
// Output type (after transforms)
type UserOutput = z.output<typeof transformSchema>
```
---
## Parsing Methods
```typescript
// .parse() - throws on error
const result = schema.parse(data)
// .safeParse() - returns result object
const result = schema.safeParse(data)
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}
// .parseAsync() - async validation
const result = await schema.parseAsync(data)
// .safeParseAsync() - async with result object
const result = await schema.safeParseAsync(data)
```
---
## Error Handling
```typescript
try {
schema.parse(data)
} catch (error) {
if (error instanceof z.ZodError) {
// Formatted errors
console.log(error.format())
// Flattened errors (for forms)
console.log(error.flatten())
// Individual issues
console.log(error.issues)
}
}
```
---
**Official Docs**: https://zod.dev

51
scripts/check-versions.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/bash
# Check Latest Versions of React Hook Form + Zod Packages
# Usage: ./check-versions.sh
echo "Checking latest package versions..."
echo ""
# Color codes
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check react-hook-form
echo -e "${GREEN}react-hook-form:${NC}"
npm view react-hook-form version
echo ""
# Check zod
echo -e "${GREEN}zod:${NC}"
npm view zod version
echo ""
# Check @hookform/resolvers
echo -e "${GREEN}@hookform/resolvers:${NC}"
npm view @hookform/resolvers version
echo ""
# Check last 5 versions of each
echo "---"
echo ""
echo -e "${YELLOW}Last 5 versions of react-hook-form:${NC}"
npm view react-hook-form versions --json | tail -7 | head -6
echo ""
echo -e "${YELLOW}Last 5 versions of zod:${NC}"
npm view zod versions --json | tail -7 | head -6
echo ""
echo -e "${YELLOW}Last 5 versions of @hookform/resolvers:${NC}"
npm view @hookform/resolvers versions --json | tail -7 | head -6
echo ""
echo "---"
echo ""
echo "Documentation Tested Versions (as of 2025-10-23):"
echo " react-hook-form: 7.65.0"
echo " zod: 4.1.12"
echo " @hookform/resolvers: 5.2.2"
echo ""
echo "Run 'npm view <package> version' to check latest"

427
templates/advanced-form.tsx Normal file
View File

@@ -0,0 +1,427 @@
/**
* Advanced Form Example - User Profile with Nested Objects and Arrays
*
* Demonstrates:
* - Nested object validation (address)
* - Array field validation (skills)
* - Conditional field validation
* - Complex Zod schemas with refinements
* - Type-safe nested error handling
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Define nested schemas
const addressSchema = z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(2, 'State must be at least 2 characters'),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code format'),
country: z.string().min(1, 'Country is required'),
})
// Complex schema with nested objects and arrays
const profileSchema = z.object({
// Basic fields
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number').optional(),
// Nested object
address: addressSchema,
// Array of strings
skills: z.array(z.string().min(1, 'Skill cannot be empty'))
.min(1, 'At least one skill is required')
.max(10, 'Maximum 10 skills allowed'),
// Conditional fields
isStudent: z.boolean(),
school: z.string().optional(),
graduationYear: z.number().int().min(1900).max(2100).optional(),
// Enum
experience: z.enum(['junior', 'mid', 'senior', 'lead'], {
errorMap: () => ({ message: 'Please select experience level' }),
}),
// Number with constraints
yearsOfExperience: z.number()
.int('Must be a whole number')
.min(0, 'Cannot be negative')
.max(50, 'Must be 50 or less'),
// Date
availableFrom: z.date().optional(),
// Boolean
agreedToTerms: z.boolean().refine((val) => val === true, {
message: 'You must agree to the terms and conditions',
}),
})
.refine((data) => {
// Conditional validation: if isStudent is true, school is required
if (data.isStudent && !data.school) {
return false
}
return true
}, {
message: 'School is required for students',
path: ['school'],
})
.refine((data) => {
// Experience level should match years of experience
if (data.experience === 'senior' && data.yearsOfExperience < 5) {
return false
}
return true
}, {
message: 'Senior level requires at least 5 years of experience',
path: ['yearsOfExperience'],
})
type ProfileFormData = z.infer<typeof profileSchema>
export function AdvancedProfileForm() {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
setValue,
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
phone: '',
address: {
street: '',
city: '',
state: '',
zipCode: '',
country: 'USA',
},
skills: [''], // Start with one empty skill
isStudent: false,
school: '',
experience: 'junior',
yearsOfExperience: 0,
agreedToTerms: false,
},
})
// Watch isStudent to conditionally show school field
const isStudent = watch('isStudent')
const onSubmit = async (data: ProfileFormData) => {
console.log('Profile data:', data)
// API call
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl mx-auto">
<h2 className="text-3xl font-bold">User Profile</h2>
{/* Basic Information */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Basic Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium mb-1">
First Name *
</label>
<input
id="firstName"
{...register('firstName')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.firstName && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.firstName.message}
</span>
)}
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium mb-1">
Last Name *
</label>
<input
id="lastName"
{...register('lastName')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.lastName && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.lastName.message}
</span>
)}
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email *
</label>
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium mb-1">
Phone (Optional)
</label>
<input
id="phone"
type="tel"
{...register('phone')}
placeholder="+1234567890"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.phone && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.phone.message}
</span>
)}
</div>
</section>
{/* Address (Nested Object) */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Address</h3>
<div>
<label htmlFor="street" className="block text-sm font-medium mb-1">
Street *
</label>
<input
id="street"
{...register('address.street')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.street && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.street.message}
</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="city" className="block text-sm font-medium mb-1">
City *
</label>
<input
id="city"
{...register('address.city')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.city && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.city.message}
</span>
)}
</div>
<div>
<label htmlFor="state" className="block text-sm font-medium mb-1">
State *
</label>
<input
id="state"
{...register('address.state')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.state && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.state.message}
</span>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="zipCode" className="block text-sm font-medium mb-1">
ZIP Code *
</label>
<input
id="zipCode"
{...register('address.zipCode')}
placeholder="12345 or 12345-6789"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.zipCode && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.zipCode.message}
</span>
)}
</div>
<div>
<label htmlFor="country" className="block text-sm font-medium mb-1">
Country *
</label>
<input
id="country"
{...register('address.country')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.country && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.country.message}
</span>
)}
</div>
</div>
</section>
{/* Skills (Array - simplified for advanced-form, see dynamic-fields.tsx for full array handling) */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Skills</h3>
<p className="text-sm text-gray-600">
Enter skills separated by commas (handled as string for simplicity in this example)
</p>
<div>
<label htmlFor="skills" className="block text-sm font-medium mb-1">
Skills (comma-separated) *
</label>
<input
id="skills"
{...register('skills.0')} // Simplified - see dynamic-fields.tsx for proper array handling
placeholder="React, TypeScript, Node.js"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.skills && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.skills.message || errors.skills[0]?.message}
</span>
)}
</div>
</section>
{/* Experience */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Experience</h3>
<div>
<label htmlFor="experience" className="block text-sm font-medium mb-1">
Experience Level *
</label>
<select
id="experience"
{...register('experience')}
className="w-full px-3 py-2 border rounded-md"
>
<option value="junior">Junior</option>
<option value="mid">Mid-Level</option>
<option value="senior">Senior</option>
<option value="lead">Lead</option>
</select>
{errors.experience && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.experience.message}
</span>
)}
</div>
<div>
<label htmlFor="yearsOfExperience" className="block text-sm font-medium mb-1">
Years of Experience *
</label>
<input
id="yearsOfExperience"
type="number"
{...register('yearsOfExperience', { valueAsNumber: true })}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.yearsOfExperience && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.yearsOfExperience.message}
</span>
)}
</div>
</section>
{/* Conditional Fields */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Education</h3>
<div className="flex items-center">
<input
id="isStudent"
type="checkbox"
{...register('isStudent')}
className="h-4 w-4 rounded"
/>
<label htmlFor="isStudent" className="ml-2 text-sm">
I am currently a student
</label>
</div>
{/* Conditional field - only show if isStudent is true */}
{isStudent && (
<div>
<label htmlFor="school" className="block text-sm font-medium mb-1">
School Name *
</label>
<input
id="school"
{...register('school')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.school && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.school.message}
</span>
)}
</div>
)}
</section>
{/* Terms and Conditions */}
<section className="space-y-4">
<div className="flex items-start">
<input
id="agreedToTerms"
type="checkbox"
{...register('agreedToTerms')}
className="h-4 w-4 rounded mt-1"
/>
<label htmlFor="agreedToTerms" className="ml-2 text-sm">
I agree to the terms and conditions *
</label>
</div>
{errors.agreedToTerms && (
<span role="alert" className="text-sm text-red-600 block">
{errors.agreedToTerms.message}
</span>
)}
</section>
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Saving...' : 'Save Profile'}
</button>
</form>
)
}

View File

@@ -0,0 +1,402 @@
/**
* Async Validation Example
*
* Demonstrates:
* - Async validation with API calls
* - Debouncing to prevent excessive requests
* - Loading states
* - Error handling for async validation
* - Request cancellation
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useState, useRef, useEffect } from 'react'
/**
* Pattern 1: Async Validation in Zod Schema
*/
const usernameSchema = z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must not exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores')
.refine(async (username) => {
// Check if username is available via API
const response = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`)
const { available } = await response.json()
return available
}, {
message: 'Username is already taken',
})
const signupSchemaWithAsync = z.object({
username: usernameSchema,
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
type SignupFormData = z.infer<typeof signupSchemaWithAsync>
export function AsyncValidationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValidating },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchemaWithAsync),
mode: 'onBlur', // Validate on blur to avoid validating on every keystroke
defaultValues: {
username: '',
email: '',
password: '',
},
})
const onSubmit = async (data: SignupFormData) => {
console.log('Form data:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<h2 className="text-2xl font-bold">Sign Up</h2>
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
{...register('username')}
className="w-full px-3 py-2 border rounded-md"
/>
{isValidating && (
<span className="text-sm text-blue-600 mt-1 block">
Checking availability...
</span>
)}
{errors.username && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.username.message}
</span>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
{...register('password')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.password && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.password.message}
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting || isValidating}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</button>
</form>
)
}
/**
* Pattern 2: Manual Async Validation with Debouncing and Cancellation
* Better performance - more control over when validation happens
*/
const manualValidationSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must not exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
email: z.string().email('Invalid email address'),
})
type ManualValidationData = z.infer<typeof manualValidationSchema>
export function DebouncedAsyncValidationForm() {
const {
register,
handleSubmit,
watch,
setError,
clearErrors,
formState: { errors, isSubmitting },
} = useForm<ManualValidationData>({
resolver: zodResolver(manualValidationSchema),
defaultValues: {
username: '',
email: '',
},
})
const [isCheckingUsername, setIsCheckingUsername] = useState(false)
const [isCheckingEmail, setIsCheckingEmail] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const username = watch('username')
const email = watch('email')
// Debounced username validation
useEffect(() => {
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Skip if username is too short (already handled by Zod)
if (!username || username.length < 3) {
setIsCheckingUsername(false)
return
}
// Debounce: wait 500ms after user stops typing
timeoutRef.current = setTimeout(async () => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
// Create new abort controller
abortControllerRef.current = new AbortController()
setIsCheckingUsername(true)
clearErrors('username')
try {
const response = await fetch(
`/api/check-username?username=${encodeURIComponent(username)}`,
{ signal: abortControllerRef.current.signal }
)
const { available } = await response.json()
if (!available) {
setError('username', {
type: 'async',
message: 'Username is already taken',
})
}
} catch (error: any) {
if (error.name !== 'AbortError') {
console.error('Username check error:', error)
}
} finally {
setIsCheckingUsername(false)
}
}, 500) // 500ms debounce
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [username, setError, clearErrors])
// Debounced email validation
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Basic email validation first (handled by Zod)
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setIsCheckingEmail(false)
return
}
timeoutRef.current = setTimeout(async () => {
setIsCheckingEmail(true)
clearErrors('email')
try {
const response = await fetch(
`/api/check-email?email=${encodeURIComponent(email)}`
)
const { available } = await response.json()
if (!available) {
setError('email', {
type: 'async',
message: 'Email is already registered',
})
}
} catch (error) {
console.error('Email check error:', error)
} finally {
setIsCheckingEmail(false)
}
}, 500)
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [email, setError, clearErrors])
const onSubmit = async (data: ManualValidationData) => {
// Final check before submission
if (isCheckingUsername || isCheckingEmail) {
return
}
console.log('Form data:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<h2 className="text-2xl font-bold">Create Account</h2>
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<div className="relative">
<input
id="username"
{...register('username')}
className="w-full px-3 py-2 border rounded-md"
/>
{isCheckingUsername && (
<div className="absolute right-3 top-2.5">
<svg
className="animate-spin h-5 w-5 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
)}
</div>
{errors.username && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.username.message}
</span>
)}
{!errors.username && username.length >= 3 && !isCheckingUsername && (
<span className="text-sm text-green-600 mt-1 block">
Username is available
</span>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<div className="relative">
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{isCheckingEmail && (
<div className="absolute right-3 top-2.5">
<svg
className="animate-spin h-5 w-5 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
)}
</div>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
{!errors.email && email && !isCheckingEmail && (
<span className="text-sm text-green-600 mt-1 block">
Email is available
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting || isCheckingUsername || isCheckingEmail}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</button>
</form>
)
}
/**
* Mock API endpoints for testing
*/
export async function checkUsernameAvailability(username: string): Promise<boolean> {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000))
// Mock: usernames starting with 'test' are taken
return !username.toLowerCase().startsWith('test')
}
export async function checkEmailAvailability(email: string): Promise<boolean> {
await new Promise(resolve => setTimeout(resolve, 1000))
// Mock: emails with 'test' are taken
return !email.toLowerCase().includes('test')
}

285
templates/basic-form.tsx Normal file
View File

@@ -0,0 +1,285 @@
/**
* Basic Form Example - Login/Signup Form
*
* Demonstrates:
* - Simple form with email and password validation
* - useForm hook with zodResolver
* - Error display
* - Type-safe form data with z.infer
* - Accessible error messages
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// 1. Define Zod validation schema
const loginSchema = z.object({
email: z.string()
.min(1, 'Email is required')
.email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
rememberMe: z.boolean().optional(),
})
// 2. Infer TypeScript type from schema
type LoginFormData = z.infer<typeof loginSchema>
export function BasicLoginForm() {
// 3. Initialize form with zodResolver
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
reset,
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
mode: 'onBlur', // Validate on blur for better UX
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
})
// 4. Handle form submission
const onSubmit = async (data: LoginFormData) => {
try {
console.log('Form data:', data)
// Make API call
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Login failed')
}
const result = await response.json()
console.log('Login successful:', result)
// Reset form after successful submission
reset()
} catch (error) {
console.error('Login error:', error)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<h2 className="text-2xl font-bold">Login</h2>
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
className={`w-full px-3 py-2 border rounded-md ${
errors.email ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="you@example.com"
/>
{errors.email && (
<span
id="email-error"
role="alert"
className="text-sm text-red-600 mt-1 block"
>
{errors.email.message}
</span>
)}
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
{...register('password')}
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby={errors.password ? 'password-error' : undefined}
className={`w-full px-3 py-2 border rounded-md ${
errors.password ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="••••••••"
/>
{errors.password && (
<span
id="password-error"
role="alert"
className="text-sm text-red-600 mt-1 block"
>
{errors.password.message}
</span>
)}
</div>
{/* Remember Me Checkbox */}
<div className="flex items-center">
<input
id="rememberMe"
type="checkbox"
{...register('rememberMe')}
className="h-4 w-4 rounded"
/>
<label htmlFor="rememberMe" className="ml-2 text-sm">
Remember me
</label>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
{/* Form Status */}
<div className="text-sm text-gray-600">
{isValid && !isSubmitting && (
<span className="text-green-600">Form is valid </span>
)}
</div>
</form>
)
}
/**
* Signup Form Variant
*/
const signupSchema = loginSchema.extend({
confirmPassword: z.string(),
name: z.string().min(2, 'Name must be at least 2 characters'),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
type SignupFormData = z.infer<typeof signupSchema>
export function BasicSignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
rememberMe: false,
},
})
const onSubmit = async (data: SignupFormData) => {
console.log('Signup data:', data)
// API call
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<h2 className="text-2xl font-bold">Sign Up</h2>
{/* Name Field */}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Full Name
</label>
<input
id="name"
{...register('name')}
className="w-full px-3 py-2 border rounded-md"
placeholder="John Doe"
/>
{errors.name && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.name.message}
</span>
)}
</div>
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
placeholder="you@example.com"
/>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
{...register('password')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.password && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.password.message}
</span>
)}
</div>
{/* Confirm Password Field */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.confirmPassword && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.confirmPassword.message}
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Creating account...' : 'Sign Up'}
</button>
</form>
)
}

View File

@@ -0,0 +1,303 @@
/**
* Custom Error Display Example
*
* Demonstrates:
* - Custom error component
* - Error summary at top of form
* - Toast notifications for errors
* - Inline vs summary error display
* - Accessible error announcements
* - Icon-based error styling
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useEffect, useState } from 'react'
const formSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
age: z.number().min(18, 'You must be at least 18 years old'),
})
type FormData = z.infer<typeof formSchema>
/**
* Custom Error Component
*/
function FormError({ message, icon = true }: { message: string; icon?: boolean }) {
return (
<div role="alert" className="flex items-start gap-2 text-sm text-red-600 mt-1">
{icon && (
<svg className="w-4 h-4 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
)}
<span>{message}</span>
</div>
)
}
/**
* Error Summary Component
*/
function ErrorSummary({ errors }: { errors: Record<string, any> }) {
const errorEntries = Object.entries(errors).filter(([key, value]) => value?.message)
if (errorEntries.length === 0) return null
return (
<div
role="alert"
aria-live="assertive"
className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6"
>
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<h3 className="font-medium text-red-900">
{errorEntries.length} {errorEntries.length === 1 ? 'Error' : 'Errors'} Found
</h3>
</div>
<ul className="list-disc list-inside space-y-1 text-sm text-red-700">
{errorEntries.map(([field, error]) => (
<li key={field}>
<strong className="capitalize">{field}:</strong> {error.message}
</li>
))}
</ul>
</div>
)
}
/**
* Toast Notification for Errors
*/
function ErrorToast({ message, onClose }: { message: string; onClose: () => void }) {
useEffect(() => {
const timer = setTimeout(onClose, 5000)
return () => clearTimeout(timer)
}, [onClose])
return (
<div className="fixed bottom-4 right-4 bg-red-600 text-white px-6 py-4 rounded-lg shadow-lg flex items-start gap-3 max-w-sm animate-slide-in">
<svg className="w-6 h-6 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<div className="flex-1">
<h4 className="font-medium">Validation Error</h4>
<p className="text-sm mt-1">{message}</p>
</div>
<button
onClick={onClose}
className="text-white hover:text-gray-200"
aria-label="Close notification"
>
</button>
</div>
)
}
/**
* Form with Custom Error Display
*/
export function CustomErrorDisplayForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
username: '',
email: '',
password: '',
age: 18,
},
})
const [toastMessage, setToastMessage] = useState<string | null>(null)
const onSubmit = async (data: FormData) => {
console.log('Form data:', data)
setToastMessage('Form submitted successfully!')
}
const onError = (errors: any) => {
// Show toast on validation error
const errorCount = Object.keys(errors).length
setToastMessage(`Please fix ${errorCount} error${errorCount > 1 ? 's' : ''} before submitting`)
}
return (
<div className="max-w-2xl mx-auto">
<form onSubmit={handleSubmit(onSubmit, onError)} className="space-y-6">
<h2 className="text-2xl font-bold">Registration Form</h2>
{/* Error Summary */}
<ErrorSummary errors={errors} />
{/* Username */}
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username *
</label>
<input
id="username"
{...register('username')}
aria-invalid={errors.username ? 'true' : 'false'}
aria-describedby={errors.username ? 'username-error' : undefined}
className={`w-full px-3 py-2 border rounded-md ${
errors.username ? 'border-red-500 focus:ring-red-500' : 'border-gray-300'
}`}
/>
{errors.username && (
<FormError message={errors.username.message!} />
)}
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email *
</label>
<input
id="email"
type="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
className={`w-full px-3 py-2 border rounded-md ${
errors.email ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.email && (
<FormError message={errors.email.message!} />
)}
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password *
</label>
<input
id="password"
type="password"
{...register('password')}
aria-invalid={errors.password ? 'true' : 'false'}
className={`w-full px-3 py-2 border rounded-md ${
errors.password ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.password && (
<FormError message={errors.password.message!} />
)}
</div>
{/* Age */}
<div>
<label htmlFor="age" className="block text-sm font-medium mb-1">
Age *
</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })}
aria-invalid={errors.age ? 'true' : 'false'}
className={`w-full px-3 py-2 border rounded-md ${
errors.age ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.age && (
<FormError message={errors.age.message!} />
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
{/* Toast Notification */}
{toastMessage && (
<ErrorToast message={toastMessage} onClose={() => setToastMessage(null)} />
)}
<style>{`
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
`}</style>
</div>
)
}
/**
* Alternative: Grouped Error Display
*/
export function GroupedErrorDisplayForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema),
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))} className="max-w-2xl mx-auto space-y-6">
<h2 className="text-2xl font-bold">Grouped Error Display</h2>
{/* All errors in single container */}
{Object.keys(errors).length > 0 && (
<div className="bg-red-50 border-l-4 border-red-600 p-4">
<h3 className="font-medium text-red-900 mb-2">Please correct the following:</h3>
<div className="space-y-2">
{Object.entries(errors).map(([field, error]) => (
<div key={field} className="flex items-start gap-2 text-sm text-red-700">
<span className="font-medium capitalize">{field}:</span>
<span>{error.message}</span>
</div>
))}
</div>
</div>
)}
{/* Form fields without individual error messages */}
<input {...register('username')} placeholder="Username" className="w-full px-3 py-2 border rounded" />
<input {...register('email')} placeholder="Email" className="w-full px-3 py-2 border rounded" />
<input {...register('password')} type="password" placeholder="Password" className="w-full px-3 py-2 border rounded" />
<input {...register('age', { valueAsNumber: true })} type="number" placeholder="Age" className="w-full px-3 py-2 border rounded" />
<button type="submit" className="w-full px-4 py-2 bg-blue-600 text-white rounded">
Submit
</button>
</form>
)
}

View File

@@ -0,0 +1,308 @@
/**
* Dynamic Form Fields Example - useFieldArray
*
* Demonstrates:
* - useFieldArray for dynamic add/remove functionality
* - Array validation with Zod
* - Proper key usage (field.id, not index)
* - Nested field error handling
* - Add, remove, update, insert operations
*/
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Schema for contact list
const contactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number').optional(),
isPrimary: z.boolean().optional(),
})
const contactListSchema = z.object({
contacts: z.array(contactSchema)
.min(1, 'At least one contact is required')
.max(10, 'Maximum 10 contacts allowed'),
})
type ContactListData = z.infer<typeof contactListSchema>
export function DynamicContactList() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<ContactListData>({
resolver: zodResolver(contactListSchema),
defaultValues: {
contacts: [{ name: '', email: '', phone: '', isPrimary: false }],
},
})
const { fields, append, remove, insert, update } = useFieldArray({
control,
name: 'contacts',
})
const onSubmit = (data: ContactListData) => {
console.log('Contacts:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold">Contact List</h2>
{/* Array error (min/max length) */}
{errors.contacts && !Array.isArray(errors.contacts) && (
<div role="alert" className="text-sm text-red-600 bg-red-50 p-3 rounded">
{errors.contacts.message}
</div>
)}
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id} // IMPORTANT: Use field.id, not index
className="border rounded-lg p-4 space-y-3"
>
<div className="flex justify-between items-center">
<h3 className="font-medium">Contact {index + 1}</h3>
<button
type="button"
onClick={() => remove(index)}
className="text-red-600 hover:text-red-800 text-sm"
disabled={fields.length === 1} // Require at least one contact
>
Remove
</button>
</div>
{/* Name */}
<div>
<label htmlFor={`contacts.${index}.name`} className="block text-sm font-medium mb-1">
Name *
</label>
<input
id={`contacts.${index}.name`}
{...register(`contacts.${index}.name` as const)}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.contacts?.[index]?.name && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.contacts[index]?.name?.message}
</span>
)}
</div>
{/* Email */}
<div>
<label htmlFor={`contacts.${index}.email`} className="block text-sm font-medium mb-1">
Email *
</label>
<input
id={`contacts.${index}.email`}
type="email"
{...register(`contacts.${index}.email` as const)}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.contacts?.[index]?.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.contacts[index]?.email?.message}
</span>
)}
</div>
{/* Phone */}
<div>
<label htmlFor={`contacts.${index}.phone`} className="block text-sm font-medium mb-1">
Phone (Optional)
</label>
<input
id={`contacts.${index}.phone`}
type="tel"
{...register(`contacts.${index}.phone` as const)}
placeholder="+1234567890"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.contacts?.[index]?.phone && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.contacts[index]?.phone?.message}
</span>
)}
</div>
{/* Primary Contact Checkbox */}
<div className="flex items-center">
<input
id={`contacts.${index}.isPrimary`}
type="checkbox"
{...register(`contacts.${index}.isPrimary` as const)}
className="h-4 w-4 rounded"
/>
<label htmlFor={`contacts.${index}.isPrimary`} className="ml-2 text-sm">
Primary contact
</label>
</div>
</div>
))}
</div>
{/* Add Contact Button */}
<div className="flex gap-2">
<button
type="button"
onClick={() => append({ name: '', email: '', phone: '', isPrimary: false })}
disabled={fields.length >= 10}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
>
Add Contact
</button>
<button
type="button"
onClick={() => insert(0, { name: '', email: '', phone: '', isPrimary: false })}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Add at Top
</button>
</div>
{/* Submit */}
<button type="submit" className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
Save Contacts
</button>
</form>
)
}
/**
* Advanced Example: Skills with Custom Add
*/
const skillSchema = z.object({
name: z.string().min(1, 'Skill name is required'),
level: z.enum(['beginner', 'intermediate', 'advanced', 'expert']),
yearsOfExperience: z.number().int().min(0).max(50),
})
const skillsFormSchema = z.object({
skills: z.array(skillSchema).min(1, 'Add at least one skill'),
})
type SkillsFormData = z.infer<typeof skillsFormSchema>
export function DynamicSkillsForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<SkillsFormData>({
resolver: zodResolver(skillsFormSchema),
defaultValues: {
skills: [],
},
})
const { fields, append, remove } = useFieldArray({
control,
name: 'skills',
})
// Preset skill templates
const addPresetSkill = (skillName: string) => {
append({
name: skillName,
level: 'intermediate',
yearsOfExperience: 1,
})
}
const onSubmit = (data: SkillsFormData) => {
console.log('Skills:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold">Your Skills</h2>
{errors.skills && !Array.isArray(errors.skills) && (
<div role="alert" className="text-sm text-red-600">
{errors.skills.message}
</div>
)}
{/* Preset Skills */}
<div className="space-y-2">
<h3 className="text-sm font-medium">Quick Add:</h3>
<div className="flex flex-wrap gap-2">
{['React', 'TypeScript', 'Node.js', 'Python', 'SQL'].map((skill) => (
<button
key={skill}
type="button"
onClick={() => addPresetSkill(skill)}
className="px-3 py-1 bg-gray-200 rounded-full text-sm hover:bg-gray-300"
>
+ {skill}
</button>
))}
</div>
</div>
{/* Skills List */}
<div className="space-y-3">
{fields.map((field, index) => (
<div key={field.id} className="border rounded p-3 flex gap-3 items-start">
<div className="flex-1 space-y-2">
<input
{...register(`skills.${index}.name` as const)}
placeholder="Skill name"
className="w-full px-2 py-1 border rounded text-sm"
/>
{errors.skills?.[index]?.name && (
<span className="text-xs text-red-600">{errors.skills[index]?.name?.message}</span>
)}
<div className="grid grid-cols-2 gap-2">
<select
{...register(`skills.${index}.level` as const)}
className="px-2 py-1 border rounded text-sm"
>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
<option value="expert">Expert</option>
</select>
<input
type="number"
{...register(`skills.${index}.yearsOfExperience` as const, { valueAsNumber: true })}
placeholder="Years"
className="px-2 py-1 border rounded text-sm"
/>
</div>
</div>
<button
type="button"
onClick={() => remove(index)}
className="text-red-600 hover:text-red-800 text-sm px-2"
>
</button>
</div>
))}
</div>
{/* Custom Add */}
<button
type="button"
onClick={() => append({ name: '', level: 'beginner', yearsOfExperience: 0 })}
className="w-full px-4 py-2 border-2 border-dashed border-gray-300 rounded-md hover:border-gray-400 text-gray-600"
>
+ Add Custom Skill
</button>
<button type="submit" className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
Save Skills
</button>
</form>
)
}

View File

@@ -0,0 +1,367 @@
/**
* Multi-Step Form Example (Wizard)
*
* Demonstrates:
* - Multi-step form with per-step validation
* - Progress tracking
* - Step navigation (next, previous)
* - Partial schema validation
* - Combined schema for final submission
* - Preserving form state across steps
*/
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Step 1: Personal Information
const step1Schema = z.object({
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number'),
})
// Step 2: Address
const step2Schema = z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(2, 'State must be at least 2 characters'),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
})
// Step 3: Account
const step3Schema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain uppercase letter')
.regex(/[0-9]/, 'Password must contain number'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
// Combined schema for final validation
const fullFormSchema = step1Schema.merge(step2Schema).merge(step3Schema)
type FormData = z.infer<typeof fullFormSchema>
type Step1Data = z.infer<typeof step1Schema>
type Step2Data = z.infer<typeof step2Schema>
type Step3Data = z.infer<typeof step3Schema>
const TOTAL_STEPS = 3
export function MultiStepRegistrationForm() {
const [currentStep, setCurrentStep] = useState(1)
const {
register,
handleSubmit,
trigger,
getValues,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(fullFormSchema),
mode: 'onChange', // Validate as user types
defaultValues: {
firstName: '',
lastName: '',
email: '',
phone: '',
street: '',
city: '',
state: '',
zipCode: '',
username: '',
password: '',
confirmPassword: '',
},
})
// Navigate to next step
const nextStep = async () => {
let fieldsToValidate: (keyof FormData)[] = []
if (currentStep === 1) {
fieldsToValidate = ['firstName', 'lastName', 'email', 'phone']
} else if (currentStep === 2) {
fieldsToValidate = ['street', 'city', 'state', 'zipCode']
}
// Trigger validation for current step fields
const isValid = await trigger(fieldsToValidate)
if (isValid) {
setCurrentStep((prev) => Math.min(prev + 1, TOTAL_STEPS))
}
}
// Navigate to previous step
const prevStep = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1))
}
// Final form submission
const onSubmit = async (data: FormData) => {
console.log('Complete form data:', data)
// Make API call
alert('Form submitted successfully!')
}
// Calculate progress percentage
const progressPercentage = (currentStep / TOTAL_STEPS) * 100
return (
<div className="max-w-2xl mx-auto">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex justify-between mb-2">
<span className="text-sm font-medium text-gray-700">
Step {currentStep} of {TOTAL_STEPS}
</span>
<span className="text-sm font-medium text-gray-700">
{Math.round(progressPercentage)}% Complete
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div>
{/* Step Indicators */}
<div className="flex justify-between mt-4">
{[1, 2, 3].map((step) => (
<div
key={step}
className={`flex items-center ${
step < TOTAL_STEPS ? 'flex-1' : ''
}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step < currentStep
? 'bg-green-600 text-white'
: step === currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-300 text-gray-600'
}`}
>
{step < currentStep ? '✓' : step}
</div>
{step < TOTAL_STEPS && (
<div
className={`flex-1 h-1 mx-2 ${
step < currentStep ? 'bg-green-600' : 'bg-gray-300'
}`}
/>
)}
</div>
))}
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Step 1: Personal Information */}
{currentStep === 1 && (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Personal Information</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">First Name *</label>
<input
{...register('firstName')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.firstName && (
<span className="text-sm text-red-600">{errors.firstName.message}</span>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name *</label>
<input
{...register('lastName')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.lastName && (
<span className="text-sm text-red-600">{errors.lastName.message}</span>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email *</label>
<input
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.email && (
<span className="text-sm text-red-600">{errors.email.message}</span>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone *</label>
<input
type="tel"
{...register('phone')}
placeholder="+1234567890"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.phone && (
<span className="text-sm text-red-600">{errors.phone.message}</span>
)}
</div>
</div>
)}
{/* Step 2: Address */}
{currentStep === 2 && (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Address</h2>
<div>
<label className="block text-sm font-medium mb-1">Street Address *</label>
<input
{...register('street')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.street && (
<span className="text-sm text-red-600">{errors.street.message}</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">City *</label>
<input
{...register('city')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.city && (
<span className="text-sm text-red-600">{errors.city.message}</span>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">State *</label>
<input
{...register('state')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.state && (
<span className="text-sm text-red-600">{errors.state.message}</span>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">ZIP Code *</label>
<input
{...register('zipCode')}
placeholder="12345 or 12345-6789"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.zipCode && (
<span className="text-sm text-red-600">{errors.zipCode.message}</span>
)}
</div>
</div>
)}
{/* Step 3: Account */}
{currentStep === 3 && (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Create Account</h2>
<div>
<label className="block text-sm font-medium mb-1">Username *</label>
<input
{...register('username')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.username && (
<span className="text-sm text-red-600">{errors.username.message}</span>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Password *</label>
<input
type="password"
{...register('password')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.password && (
<span className="text-sm text-red-600">{errors.password.message}</span>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm Password *</label>
<input
type="password"
{...register('confirmPassword')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.confirmPassword && (
<span className="text-sm text-red-600">{errors.confirmPassword.message}</span>
)}
</div>
{/* Summary */}
<div className="mt-6 p-4 bg-gray-50 rounded-md">
<h3 className="font-medium mb-2">Review Your Information:</h3>
<div className="text-sm space-y-1">
<p><strong>Name:</strong> {getValues('firstName')} {getValues('lastName')}</p>
<p><strong>Email:</strong> {getValues('email')}</p>
<p><strong>Phone:</strong> {getValues('phone')}</p>
<p><strong>Address:</strong> {getValues('street')}, {getValues('city')}, {getValues('state')} {getValues('zipCode')}</p>
<p><strong>Username:</strong> {getValues('username')}</p>
</div>
</div>
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-between pt-4">
<button
type="button"
onClick={prevStep}
disabled={currentStep === 1}
className="px-6 py-2 border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
{currentStep < TOTAL_STEPS ? (
<button
type="button"
onClick={nextStep}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Next
</button>
) : (
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Submitting...' : 'Complete Registration'}
</button>
)}
</div>
</form>
</div>
)
}

35
templates/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "react-hook-form-zod-example",
"version": "1.0.0",
"description": "Example project demonstrating React Hook Form + Zod validation",
"private": true,
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.1",
"zod": "^4.1.12",
"@hookform/resolvers": "^5.2.2"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"vite": "^6.3.0",
"@vitejs/plugin-react": "^4.3.0"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"keywords": [
"react",
"react-hook-form",
"zod",
"validation",
"forms",
"typescript"
],
"author": "",
"license": "MIT"
}

View File

@@ -0,0 +1,313 @@
/**
* Server-Side Validation Example
*
* Demonstrates:
* - Using the SAME Zod schema on server
* - Single source of truth for validation
* - Error mapping from server to client
* - Type-safe validation on both sides
*/
import { z } from 'zod'
/**
* SHARED SCHEMA - Use this exact schema on both client and server
* Define it in a shared file (e.g., schemas/user.ts) and import on both sides
*/
export const userRegistrationSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must not exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
email: z.string()
.email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
age: z.number()
.int('Age must be a whole number')
.min(13, 'You must be at least 13 years old')
.max(120, 'Invalid age'),
}).refine((data) => {
// Custom validation: check if username is blacklisted
const blacklistedUsernames = ['admin', 'root', 'system']
return !blacklistedUsernames.includes(data.username.toLowerCase())
}, {
message: 'This username is not allowed',
path: ['username'],
})
type UserRegistrationData = z.infer<typeof userRegistrationSchema>
/**
* SERVER-SIDE VALIDATION (Next.js API Route Example)
*/
export async function POST(request: Request) {
try {
const body = await request.json()
// 1. Parse and validate with Zod
const validatedData = userRegistrationSchema.parse(body)
// 2. Additional server-only validation (database checks, etc.)
const usernameExists = await checkUsernameExists(validatedData.username)
if (usernameExists) {
return Response.json(
{
success: false,
errors: {
username: 'Username is already taken',
},
},
{ status: 400 }
)
}
const emailExists = await checkEmailExists(validatedData.email)
if (emailExists) {
return Response.json(
{
success: false,
errors: {
email: 'Email is already registered',
},
},
{ status: 400 }
)
}
// 3. Proceed with registration
const user = await createUser(validatedData)
return Response.json({
success: true,
user: {
id: user.id,
username: user.username,
email: user.email,
},
})
} catch (error) {
// 4. Handle Zod validation errors
if (error instanceof z.ZodError) {
return Response.json(
{
success: false,
errors: error.flatten().fieldErrors,
},
{ status: 400 }
)
}
// 5. Handle other errors
console.error('Registration error:', error)
return Response.json(
{
success: false,
message: 'An unexpected error occurred',
},
{ status: 500 }
)
}
}
/**
* SERVER-SIDE VALIDATION (Node.js/Express Example)
*/
import express from 'express'
const app = express()
app.post('/api/register', async (req, res) => {
try {
// Parse and validate
const validatedData = userRegistrationSchema.parse(req.body)
// Server-only checks
const usernameExists = await checkUsernameExists(validatedData.username)
if (usernameExists) {
return res.status(400).json({
success: false,
errors: {
username: 'Username is already taken',
},
})
}
// Create user
const user = await createUser(validatedData)
res.json({
success: true,
user,
})
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
errors: error.flatten().fieldErrors,
})
}
console.error('Registration error:', error)
res.status(500).json({
success: false,
message: 'An unexpected error occurred',
})
}
})
/**
* SERVER-SIDE VALIDATION (Cloudflare Workers + Hono Example)
*/
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
const app = new Hono()
app.post('/api/register', zValidator('json', userRegistrationSchema), async (c) => {
// Data is already validated by zValidator middleware
const validatedData = c.req.valid('json')
// Server-only checks
const usernameExists = await checkUsernameExists(validatedData.username)
if (usernameExists) {
return c.json(
{
success: false,
errors: {
username: 'Username is already taken',
},
},
400
)
}
// Create user
const user = await createUser(validatedData)
return c.json({
success: true,
user,
})
})
/**
* CLIENT-SIDE INTEGRATION WITH SERVER ERRORS
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
function RegistrationForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<UserRegistrationData>({
resolver: zodResolver(userRegistrationSchema),
defaultValues: {
username: '',
email: '',
password: '',
age: 18,
},
})
const onSubmit = async (data: UserRegistrationData) => {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await response.json()
if (!result.success) {
// Map server errors to form fields
if (result.errors) {
Object.entries(result.errors).forEach(([field, message]) => {
setError(field as keyof UserRegistrationData, {
type: 'server',
message: Array.isArray(message) ? message[0] : message as string,
})
})
} else {
// Generic error
setError('root', {
type: 'server',
message: result.message || 'Registration failed',
})
}
return
}
// Success - redirect or show success message
console.log('Registration successful:', result.user)
} catch (error) {
setError('root', {
type: 'server',
message: 'Network error. Please try again.',
})
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && (
<div role="alert" className="error-banner">
{errors.root.message}
</div>
)}
{/* Form fields */}
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
)
}
/**
* Helper functions (implement according to your database)
*/
async function checkUsernameExists(username: string): Promise<boolean> {
// Database query
return false
}
async function checkEmailExists(email: string): Promise<boolean> {
// Database query
return false
}
async function createUser(data: UserRegistrationData) {
// Create user in database
return { id: '1', ...data }
}
/**
* KEY BENEFITS OF SERVER-SIDE VALIDATION:
*
* 1. Security - Client validation can be bypassed, server validation cannot
* 2. Single Source of Truth - Same schema on client and server
* 3. Type Safety - TypeScript types automatically inferred from schema
* 4. Consistency - Same validation rules applied everywhere
* 5. Database Checks - Server can validate against database (unique username, etc.)
*/

351
templates/shadcn-form.tsx Normal file
View File

@@ -0,0 +1,351 @@
/**
* shadcn/ui Form Component Integration
*
* Demonstrates:
* - shadcn/ui Form component with React Hook Form + Zod
* - FormField, FormItem, FormLabel, FormControl, FormMessage components
* - Type-safe form with proper error handling
* - Accessible form structure
*
* Installation:
* npx shadcn@latest add form input button
*/
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from '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'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
// Define schema
const profileFormSchema = z.object({
username: z.string()
.min(2, { message: 'Username must be at least 2 characters.' })
.max(30, { message: 'Username must not be longer than 30 characters.' }),
email: z.string()
.email({ message: 'Please enter a valid email address.' }),
bio: z.string()
.max(160, { message: 'Bio must not be longer than 160 characters.' })
.optional(),
role: z.enum(['admin', 'user', 'guest'], {
required_error: 'Please select a role.',
}),
notifications: z.boolean().default(false).optional(),
})
type ProfileFormValues = z.infer<typeof profileFormSchema>
export function ShadcnProfileForm() {
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema),
defaultValues: {
username: '',
email: '',
bio: '',
notifications: false,
},
})
function onSubmit(data: ProfileFormValues) {
console.log('Form submitted:', data)
// Make API call
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 max-w-2xl mx-auto">
<h2 className="text-3xl font-bold">Profile Settings</h2>
{/* Username Field */}
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public 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>
<FormDescription>
We'll never share your email with anyone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Bio Field (Textarea) */}
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us a little bit about yourself"
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
You can write up to 160 characters.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Role Field (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="user">User</SelectItem>
<SelectItem value="guest">Guest</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose your account type.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Notifications Field (Checkbox) */}
<FormField
control={form.control}
name="notifications"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Email notifications
</FormLabel>
<FormDescription>
Receive email notifications about your account activity.
</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Update profile'}
</Button>
</form>
</Form>
)
}
/**
* Multiple Field Types Example
*/
const settingsFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
language: z.string(),
theme: z.enum(['light', 'dark', 'system']),
emailPreferences: z.object({
marketing: z.boolean().default(false),
updates: z.boolean().default(true),
security: z.boolean().default(true),
}),
})
type SettingsFormValues = z.infer<typeof settingsFormSchema>
export function ShadcnSettingsForm() {
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsFormSchema),
defaultValues: {
name: '',
language: 'en',
theme: 'system',
emailPreferences: {
marketing: false,
updates: true,
security: true,
},
},
})
function onSubmit(data: SettingsFormValues) {
console.log('Settings updated:', data)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 max-w-2xl mx-auto">
<h2 className="text-3xl font-bold">Settings</h2>
{/* Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Theme */}
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Email Preferences (Nested Object with Checkboxes) */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Email Preferences</h3>
<FormField
control={form.control}
name="emailPreferences.marketing"
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>Marketing emails</FormLabel>
<FormDescription>
Receive emails about new products and features.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPreferences.updates"
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>Update emails</FormLabel>
<FormDescription>
Receive emails about your account updates.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPreferences.security"
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>Security emails</FormLabel>
<FormDescription>
Receive emails about your account security (recommended).
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
<Button type="submit">Save settings</Button>
</form>
</Form>
)
}
/**
* NOTE: shadcn/ui states "We are not actively developing the Form component anymore."
* They recommend using the Field component for new implementations.
* Check https://ui.shadcn.com/docs/components/form for latest guidance.
*/