Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal 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
3
README.md
Normal 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.
|
||||||
117
plugin.lock.json
Normal file
117
plugin.lock.json
Normal 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
359
references/accessibility.md
Normal 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
|
||||||
255
references/error-handling.md
Normal file
255
references/error-handling.md
Normal 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/
|
||||||
197
references/links-to-official-docs.md
Normal file
197
references/links-to-official-docs.md
Normal 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
|
||||||
355
references/performance-optimization.md
Normal file
355
references/performance-optimization.md
Normal 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
|
||||||
420
references/rhf-api-reference.md
Normal file
420
references/rhf-api-reference.md
Normal 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/
|
||||||
390
references/shadcn-integration.md
Normal file
390
references/shadcn-integration.md
Normal 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
381
references/top-errors.md
Normal 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/
|
||||||
396
references/zod-schemas-guide.md
Normal file
396
references/zod-schemas-guide.md
Normal 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
51
scripts/check-versions.sh
Executable 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
427
templates/advanced-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
402
templates/async-validation.tsx
Normal file
402
templates/async-validation.tsx
Normal 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
285
templates/basic-form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
303
templates/custom-error-display.tsx
Normal file
303
templates/custom-error-display.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
308
templates/dynamic-fields.tsx
Normal file
308
templates/dynamic-fields.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
367
templates/multi-step-form.tsx
Normal file
367
templates/multi-step-form.tsx
Normal 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
35
templates/package.json
Normal 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"
|
||||||
|
}
|
||||||
313
templates/server-validation.ts
Normal file
313
templates/server-validation.ts
Normal 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
351
templates/shadcn-form.tsx
Normal 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.
|
||||||
|
*/
|
||||||
Reference in New Issue
Block a user