form-patterns
2
总安装量
2
周安装量
#63943
全站排名
安装命令
npx skills add https://github.com/retrip-ai/agent-skills --skill form-patterns
Agent 安装分布
trae
2
antigravity
2
claude-code
2
github-copilot
2
codex
2
kimi-cli
2
Skill 文档
Form Patterns
Complete guide to form handling using React Hook Form with Zod validation and Field components.
Overview
This skill covers:
- React Hook Form – Form state management and validation
- Zod Integration – Type-safe schema validation
- Field Components – Styled, accessible form fields
- UnsavedChangesBar – Edit form pattern with change detection
- tRPC Integration – Form submission with mutations
When to Apply
Reference these guidelines when:
- Creating or editing forms
- Implementing form validation
- Working with form state management
- Building edit forms with unsaved changes detection
- Handling array fields or dynamic forms
- Integrating forms with tRPC mutations
Quick Reference
Form Setup
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
const schema = z.object({
email: z.string().email('Enter a valid email'),
name: z.string().min(2, 'Name required'),
});
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { email: '', name: '' },
mode: 'onBlur', // For edit forms
});
Field Pattern
import { Controller } from 'react-hook-form';
import { Field, FieldLabel, FieldError } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid || undefined}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid || undefined}
/>
{fieldState.error && (
<FieldError errors={[{ message: fieldState.error.message || '' }]} />
)}
</Field>
)}
/>
Edit Form Pattern (UnsavedChangesBar)
import { useFormState } from 'react-hook-form';
import { UnsavedChangesBar } from '@/components/unsaved-changes-bar';
const { isDirty, isSubmitting } = useFormState({ control: form.control });
const isSaving = isSubmitting || mutation.isPending;
const onSubmit = form.handleSubmit(async (value) => {
await mutation.mutateAsync(value);
form.reset(value); // Clear isDirty
});
const handleDiscard = () => form.reset();
<UnsavedChangesBar
show={isDirty}
isSaving={isSaving}
onDiscard={handleDiscard}
labels={{
unsavedChanges: 'Unsaved changes',
discard: 'Discard',
save: 'Save',
}}
/>
References
Complete documentation with examples:
references/forms.md– Comprehensive form patterns, field types, validation, integration
To find specific patterns:
grep -l "UnsavedChangesBar" references/*.md
grep -l "useFieldArray" references/*.md
grep -l "validation" references/*.md
Core Patterns
1. Basic Form
Three steps:
- Create Zod schema
- Setup useForm with zodResolver
- Build fields with Controller
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Field, FieldLabel, FieldError } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
const formSchema = z.object({
email: z.string().email('Enter a valid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
function MyForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
});
function onSubmit(data: z.infer<typeof formSchema>) {
console.log(data);
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid || undefined}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid || undefined}
/>
{fieldState.error && (
<FieldError errors={[{ message: fieldState.error.message || '' }]} />
)}
</Field>
)}
/>
<button type="submit">Submit</button>
</form>
);
}
2. Edit Forms with UnsavedChangesBar
When to use: Modifying existing data (profiles, settings, configurations)
Critical pattern:
import { useForm, useFormState, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { UnsavedChangesBar } from '@/components/unsaved-changes-bar';
import { useMutation } from '@tanstack/react-query';
import { trpc } from '@/trpc';
function EditForm({ initialData }: Props) {
// 1. Setup form with initial data
const form = useForm({
defaultValues: initialData,
resolver: zodResolver(schema),
mode: 'onBlur', // Validate on blur for better UX
});
// 2. Track dirty state reactively
const { isDirty, isSubmitting } = useFormState({ control: form.control });
// 3. Setup mutation
const mutation = useMutation(
trpc.organizations.update.mutationOptions()
);
const isSaving = isSubmitting || mutation.isPending;
// 4. Submit handler
const onSubmit = form.handleSubmit(async (value) => {
await mutation.mutateAsync(value);
form.reset(value); // CRITICAL: Reset with new values to clear isDirty
});
// 5. Discard handler
const handleDiscard = () => form.reset(); // Return to initial state
return (
<form onSubmit={onSubmit}>
{/* Fields */}
<UnsavedChangesBar
show={isDirty}
isSaving={isSaving}
onDiscard={handleDiscard}
labels={{
unsavedChanges: 'Unsaved changes',
discard: 'Discard',
save: 'Save',
}}
/>
</form>
);
}
Critical requirements:
- â
Use
useFormState({ control: form.control })for reactiveisDirty - â
Use
Controllerfor controlled inputs - â
Set
mode: 'onBlur'for field validation - â
Call
form.reset(value)after successful save (notform.reset()) - â
Use
Field,FieldLabel,FieldErrorcomponents
3. Create Forms (No UnsavedChangesBar)
When to use: Creating new entities (API keys, invitations, organizations)
function CreateForm() {
const form = useForm({
defaultValues: { name: '' },
resolver: zodResolver(schema),
mode: 'onBlur',
});
const mutation = useMutation(trpc.apiKeys.create.mutationOptions());
const onSubmit = form.handleSubmit(async (value) => {
await mutation.mutateAsync(value);
form.reset(); // Clear form after create
});
return (
<form onSubmit={onSubmit}>
{/* Fields */}
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create'}
</button>
</form>
);
}
4. Array Fields
Dynamic fields with add/remove:
import { useFieldArray } from 'react-hook-form';
const schema = z.object({
emails: z
.array(
z.object({
address: z.string().email('Enter a valid email'),
})
)
.min(1, 'Add at least one email')
.max(5, 'Maximum 5 emails'),
});
function EmailListForm() {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
emails: [{ address: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'emails',
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}> {/* Use field.id as key */}
<Controller
name={`emails.${index}.address`}
control={form.control}
render={({ field: controllerField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...controllerField}
type="email"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ address: '' })}>
Add Email
</button>
<button type="submit">Save</button>
</form>
);
}
Field Types
Input
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input {...field} id={field.name} />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
Textarea
<Controller
name="description"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Description</FieldLabel>
<Textarea {...field} id={field.name} className="min-h-[120px]" />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
Select
<Controller
name="language"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Language</FieldLabel>
<Select
name={field.name}
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger id={field.name}>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
</SelectContent>
</Select>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
Checkbox
<Controller
name="terms"
control={form.control}
render={({ field, fieldState }) => (
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
<Checkbox
id={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
<FieldLabel htmlFor={field.name}>Accept terms</FieldLabel>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
Switch
<Controller
name="twoFactor"
control={form.control}
render={({ field, fieldState }) => (
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
<FieldContent>
<FieldLabel htmlFor={field.name}>Two-factor auth</FieldLabel>
<FieldDescription>Enable multi-factor authentication</FieldDescription>
</FieldContent>
<Switch
id={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</Field>
)}
/>
Validation Modes
| Mode | Behavior | Use Case |
|---|---|---|
onBlur |
Validates when field loses focus | Edit forms (recommended) |
onChange |
Validates on every keystroke | Real-time validation |
onSubmit |
Validates only on submit | Simple forms |
onTouched |
First blur, then every change | Balance between onBlur/onChange |
all |
Both blur and change | Strict validation |
Recommendation: Use onBlur for edit forms to avoid annoying users with errors while typing.
tRPC Integration
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { trpc } from '@/trpc';
import { toast } from '@/components/ui/toast';
function MyForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
...trpc.memberProfiles.update.mutationOptions(),
onSuccess: (data) => {
toast.success('Saved successfully');
// Invalidate related queries
queryClient.invalidateQueries({
queryKey: [['memberProfiles', 'getCurrent']],
});
// Clear isDirty
form.reset(data);
},
onError: (error) => {
toast.error(error.message);
// Optionally set server errors to specific fields
if (error.data?.code === 'CONFLICT') {
form.setError('email', {
message: 'Email already exists',
});
}
},
});
const form = useForm({
defaultValues: { ... },
resolver: zodResolver(schema),
});
const onSubmit = form.handleSubmit((data) => {
mutation.mutate(data);
});
return (
<form onSubmit={onSubmit}>
{/* Fields */}
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}
Error Handling
Display Field Errors
<Field data-invalid={fieldState.invalid}>
<FieldLabel>Email</FieldLabel>
<Input {...field} aria-invalid={fieldState.invalid} />
{fieldState.invalid && (
<FieldError errors={[{ message: fieldState.error.message || '' }]} />
)}
</Field>
Set Server Errors
// In mutation onError
form.setError('email', {
message: 'Email already exists',
});
// Form-level error
form.setError('root', {
message: 'Something went wrong',
});
Best Practices
â Do:
Form Setup:
- Use Zod schemas for validation
- Use
zodResolverfor integration - Set
mode: 'onBlur'for edit forms - Provide default values
Fields:
- Use
Controllerfor all controlled inputs - Add
data-invalidto<Field> - Add
aria-invalidto form controls - Show error messages with
<FieldError> - Use semantic HTML (
htmlFor,id, propertype)
State Management:
- Use
useFormStatefor reactive states (isDirty,isSubmitting) - Reset form after successful save:
form.reset(newValue) - Handle loading states during submission
- Invalidate queries after mutations
UX:
- Show loading states (
isPending,isSubmitting) - Disable submit button while saving
- Show success/error toasts
- Use
UnsavedChangesBarfor edit forms
â Don’t:
Form Setup:
- â Use uncontrolled inputs
- â Skip validation schemas
- â Use
anytypes - â Mix controlled and uncontrolled inputs
Edit Forms:
- â Use
form.reset()without new values (won’t clearisDirty) - â Forget
useFormStatefor reactiveisDirty - â Use
UnsavedChangesBarfor create forms
Fields:
- â Skip error messages
- â Forget accessibility attributes
- â Use register() for complex components (use Controller instead)
Integration:
- â Forget to invalidate queries after mutations
- â Skip loading states
- â Ignore error handling
Common Patterns
Form with Mutation
const mutation = useMutation(trpc.organizations.update.mutationOptions());
const onSubmit = form.handleSubmit(async (data) => {
await mutation.mutateAsync(data);
form.reset(data);
});
Conditional Validation
const schema = z.object({
type: z.enum(['individual', 'business']),
businessName: z.string().optional(),
}).refine(
(data) => data.type !== 'business' || data.businessName,
{
message: 'Business name required',
path: ['businessName'],
}
);
Dependent Fields
const type = form.watch('type');
{type === 'business' && (
<Controller name="businessName" ... />
)}
Related Skills
base-ui-design– Field components and design guidelinestanstack-comprehensive– tRPC mutations and data invalidation
Version: 1.0.0 Last updated: 2026-01-14