🎨 Lesson 7.5: Advanced Form Patterns
You've mastered the fundamentals of forms, validation, and file uploads. Now it's time to level up with advanced patterns that you'll encounter in real-world applications. Think about complex forms you've filled out online—multi-step checkout processes, conditional questions that appear based on your answers, forms that save your progress automatically, or wizards that guide you through complex data entry. These aren't just nice-to-have features; they're essential for creating professional, user-friendly applications. In this lesson, you'll learn to build all of these patterns with React, TypeScript, and React Hook Form.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Build multi-step forms with typed step state and safe navigation
- Implement conditional fields that show or hide based on user input, with matching conditional validation
- Create form wizards with progress tracking and per-step validation
- Manage dynamic, repeatable field groups with React Hook Form's typed
useFieldArray - Add asynchronous validation — debounced "is this username taken?" checks against a server
- Implement auto-save with debouncing and clear save-status feedback
- Handle field dependencies, cascading dropdowns, and computed values
- Make forms accessible with correct labels, ARIA attributes, and live error announcements
- Bridge to React 19's built-in form Actions,
useActionState, anduseFormStatus, and know when to reach for them versus React Hook Form
Estimated Time: 75–90 minutes
Project: Build a complete multi-step checkout form with conditional fields, a dynamic line-item list, async validation, and auto-save
Stack: React 19 · TypeScript 5.x · React Hook Form v7 · Zod (function components throughout)
In This Lesson
🌟 Introduction to Advanced Form Patterns
Forms are the primary way users interact with web applications, and as applications grow more complex, so do their forms. Let's look at some real-world scenarios where advanced form patterns are essential:
Common Advanced Form Scenarios
- E-commerce Checkout: Multi-step process (shipping → payment → review → confirmation)
- User Onboarding: Wizard-style forms that guide new users through setup
- Insurance Applications: Conditional questions based on coverage type, age, health status
- Job Applications: Different fields for different job types, auto-save drafts
- Tax Filing Software: Complex conditional logic, field dependencies, calculations
- Survey Forms: Skip logic, branching questions, progress tracking
- Account Settings: Auto-save changes, show/hide sections, field validation
📖 What Makes a Form "Advanced"?
Advanced Form Patterns: techniques that go beyond a single page of inputs with one round of validation. They cover multi-step navigation, conditional field visibility, field dependencies and calculated values, dynamic (add/remove) field arrays, auto-save, asynchronous validation against a server, and guided flows like wizards. What makes them "advanced" isn't fancy syntax — it's the state management, the user-experience judgement, and the accessibility work that has to hold together while the form changes shape underneath the user.
Why These Patterns Matter
| Pattern | User Benefit | Business Benefit |
|---|---|---|
| Multi-Step Forms | Less overwhelming, focused attention | Higher completion rates |
| Conditional Fields | Only see relevant questions | Cleaner data, faster completion |
| Auto-Save | No lost work, peace of mind | Reduced abandonment |
| Progress Indicators | Know how far they've come | Increased completion motivation |
| Field Dependencies | Intelligent defaults, less typing | Better data quality |
| Form Wizards | Guided experience, clear path | Consistent onboarding |
✅ The Psychology of Form Design
Research shows that:
- Multi-step forms can increase conversions by up to 300% compared to long single-page forms
- Progress bars increase completion rates by giving users a sense of accomplishment
- Auto-save reduces anxiety and makes users more likely to complete longer forms
- Conditional fields reduce cognitive load by showing only relevant questions
Good form design isn't just about code—it's about understanding human behavior!
What We'll Build Together
Throughout this lesson, we'll progressively build a complete multi-step checkout form that demonstrates all these patterns:
This form will include:
- ✅ Multiple steps with navigation
- ✅ Conditional billing address step
- ✅ Field dependencies (state → cities)
- ✅ Auto-save to localStorage
- ✅ Per-step validation
- ✅ Progress indicator
- ✅ Review/summary step
- ✅ Full accessibility support
💡 Tools We'll Use
We'll leverage everything you've learned so far:
- React Hook Form: For efficient form state management
- Zod: For schema validation
- TypeScript: For type safety across complex form state
- localStorage: For persisting form data
- Custom Hooks: For reusable form logic
📊 Multi-Step Forms: The Basics
Multi-step forms break a long form into smaller, more manageable pieces. Instead of overwhelming users with 50 fields at once, you present them with 5-10 fields per step. This improves both the user experience and completion rates.
Key Concepts
1. State Management
In a multi-step form, you need to track:
- Current Step: Which step the user is on (e.g., step 1 of 4)
- Form Data: All data from all steps, even those not currently visible
- Validation State: Which steps have been completed/validated
- Step History: Where the user has been (for back button)
interface MultiStepFormState {
currentStep: number;
completedSteps: Set<number>;
formData: {
step1: Step1Data;
step2: Step2Data;
step3: Step3Data;
};
}
2. Step Components
Each step is typically its own component with its own validation schema:
// Step 1: Account Information
const AccountInfoStep = ({ onNext, defaultValues }) => {
// Form logic for this step only
// Validate and call onNext with data
};
// Step 2: Shipping Address
const ShippingStep = ({ onNext, onBack, defaultValues }) => {
// Form logic for shipping
};
// And so on...
3. Navigation Patterns
| Action | Behavior | Validation |
|---|---|---|
| Next | Move to next step | Validate current step before proceeding |
| Back | Return to previous step | No validation required (data preserved) |
| Skip | Jump to next step (if allowed) | Optional step, no validation |
| Jump to Step | Navigate to specific step | Only allow if step is completed or next in sequence |
| Submit | Final submission (last step) | Validate all steps, then submit |
⚠️ Common Pitfalls
- Losing Data: When navigating between steps, preserve all form data
- Over-Validation: Don't validate steps the user hasn't reached yet
- Unclear Progress: Always show where users are in the process
- Disabled Back Button: Users should always be able to go back and change answers
- Too Many Steps: More than 5-6 steps feels tedious. Group related fields.
Architectural Approaches
There are two main ways to structure multi-step forms:
Approach 1: Single Form with Conditional Rendering
const MultiStepForm = () => {
const [step, setStep] = useState(1);
const methods = useForm(); // Single form instance
return (
<FormProvider {...methods}>
{step === 1 && <Step1 />}
{step === 2 && <Step2 />}
{step === 3 && <Step3 />}
</FormProvider>
);
};
Pros: All data in one form, simple state management
Cons: All validation schemas load upfront, harder to code-split
Approach 2: Separate Forms with Shared State
const MultiStepForm = () => {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({});
const handleStepComplete = (stepData) => {
setFormData(prev => ({ ...prev, ...stepData }));
setStep(prev => prev + 1);
};
return (
<>
{step === 1 && <Step1Form onComplete={handleStepComplete} />}
{step === 2 && <Step2Form onComplete={handleStepComplete} />}
{step === 3 && <Step3Form onComplete={handleStepComplete} />}
</>
);
};
Pros: Better code splitting, isolated validation
Cons: More complex state management, need to pass data between steps
✅ Recommendation
For most applications, Approach 2 (separate forms with shared state) is better because:
- Better performance (lazy load steps)
- Easier to test individual steps
- More flexible validation rules per step
- Cleaner separation of concerns
We'll use this approach in our examples.
Data Flow in Multi-Step Forms
💡 Key Principle: Progressive Enhancement
Think of multi-step forms as progressive enhancement:
- Step 1: Get the minimum required information
- Step 2: Enhance with additional details
- Step 3: Optional customization
- Final Step: Review and confirm
Each step should feel like a natural progression, not an arbitrary division.
🔨 Building Your First Multi-Step Form
Let's build a practical multi-step form from scratch. We'll create a user registration form with three steps: Account Info, Personal Details, and Preferences.
Step 1: Define Types and Schemas
First, let's define our TypeScript types and Zod schemas for each step:
import { z } from 'zod';
// Step 1: Account Information
const accountInfoSchema = z.object({
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 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']
});
type AccountInfo = z.infer<typeof accountInfoSchema>;
// Step 2: Personal Details
const personalDetailsSchema = z.object({
firstName: z.string().min(2, 'First name is required'),
lastName: z.string().min(2, 'Last name is required'),
dateOfBirth: z.string().regex(
/^\d{4}-\d{2}-\d{2}$/,
'Date must be in YYYY-MM-DD format'
),
phone: z.string().regex(
/^\d{10}$/,
'Phone must be 10 digits'
).optional()
});
type PersonalDetails = z.infer<typeof personalDetailsSchema>;
// Step 3: Preferences
const preferencesSchema = z.object({
newsletter: z.boolean(),
notifications: z.enum(['all', 'important', 'none']),
interests: z.array(z.string()).min(1, 'Select at least one interest')
});
type Preferences = z.infer<typeof preferencesSchema>;
// Combined form data
interface RegistrationFormData {
accountInfo?: AccountInfo;
personalDetails?: PersonalDetails;
preferences?: Preferences;
}
Step 2: Create the Main Form Container
import React, { useState } from 'react';
const RegistrationForm: React.FC = () => {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<RegistrationFormData>({});
const totalSteps = 3;
const handleStepComplete = (stepName: keyof RegistrationFormData, data: any) => {
// Save step data
setFormData(prev => ({
...prev,
[stepName]: data
}));
// Move to next step
if (currentStep < totalSteps) {
setCurrentStep(prev => prev + 1);
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(prev => prev - 1);
}
};
const handleSubmit = async () => {
console.log('Submitting complete form:', formData);
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (response.ok) {
alert('Registration successful!');
// Redirect to login or dashboard
}
} catch (error) {
console.error('Registration failed:', error);
alert('Registration failed. Please try again.');
}
};
return (
<div className="registration-form">
<h1>Create Your Account</h1>
{/* Progress indicator */}
<div className="progress-steps">
{[1, 2, 3].map(step => (
<div
key={step}
className={`step ${step === currentStep ? 'active' : ''} ${
step < currentStep ? 'completed' : ''
}`}
>
{step}
</div>
))}
</div>
{/* Render current step */}
{currentStep === 1 && (
<AccountInfoStep
onNext={(data) => handleStepComplete('accountInfo', data)}
defaultValues={formData.accountInfo}
/>
)}
{currentStep === 2 && (
<PersonalDetailsStep
onNext={(data) => handleStepComplete('personalDetails', data)}
onBack={handleBack}
defaultValues={formData.personalDetails}
/>
)}
{currentStep === 3 && (
<PreferencesStep
onSubmit={(data) => {
handleStepComplete('preferences', data);
handleSubmit();
}}
onBack={handleBack}
defaultValues={formData.preferences}
/>
)}
</div>
);
};
export default RegistrationForm;
Step 3: Create Individual Step Components
Now let's create each step component. Here's the first step:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
interface AccountInfoStepProps {
onNext: (data: AccountInfo) => void;
defaultValues?: AccountInfo;
}
const AccountInfoStep: React.FC<AccountInfoStepProps> = ({
onNext,
defaultValues
}) => {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<AccountInfo>({
resolver: zodResolver(accountInfoSchema),
defaultValues
});
const onSubmit = (data: AccountInfo) => {
onNext(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Step 1: Account Information</h2>
<div className="form-group">
<label htmlFor="email">Email Address *</label>
<input
id="email"
type="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" className="error" role="alert">
{errors.email.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="password">Password *</label>
<input
id="password"
type="password"
{...register('password')}
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{errors.password && (
<span id="password-error" className="error" role="alert">
{errors.password.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password *</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
aria-invalid={errors.confirmPassword ? 'true' : 'false'}
aria-describedby={
errors.confirmPassword ? 'confirm-password-error' : undefined
}
/>
{errors.confirmPassword && (
<span id="confirm-password-error" className="error" role="alert">
{errors.confirmPassword.message}
</span>
)}
</div>
<div className="form-actions">
<button type="submit" className="btn-primary">
Next →
</button>
</div>
</form>
);
};
✅ Key Patterns in This Implementation
- Default Values: Each step receives previous data via
defaultValues - Independent Validation: Each step has its own schema and validates independently
- Callback Pattern: Steps call
onNextwith validated data - Type Safety: TypeScript ensures data structure is correct across steps
- Accessibility: Proper ARIA attributes for errors and form controls
Step 4: Second and Third Step Components
The other steps follow the same pattern, but with onBack support:
interface PersonalDetailsStepProps {
onNext: (data: PersonalDetails) => void;
onBack: () => void;
defaultValues?: PersonalDetails;
}
const PersonalDetailsStep: React.FC<PersonalDetailsStepProps> = ({
onNext,
onBack,
defaultValues
}) => {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<PersonalDetails>({
resolver: zodResolver(personalDetailsSchema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Step 2: Personal Details</h2>
<div className="form-group">
<label htmlFor="firstName">First Name *</label>
<input
id="firstName"
{...register('firstName')}
aria-invalid={errors.firstName ? 'true' : 'false'}
/>
{errors.firstName && (
<span className="error" role="alert">
{errors.firstName.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="lastName">Last Name *</label>
<input
id="lastName"
{...register('lastName')}
aria-invalid={errors.lastName ? 'true' : 'false'}
/>
{errors.lastName && (
<span className="error" role="alert">
{errors.lastName.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="dateOfBirth">Date of Birth *</label>
<input
id="dateOfBirth"
type="date"
{...register('dateOfBirth')}
aria-invalid={errors.dateOfBirth ? 'true' : 'false'}
/>
{errors.dateOfBirth && (
<span className="error" role="alert">
{errors.dateOfBirth.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="phone">Phone (Optional)</label>
<input
id="phone"
type="tel"
{...register('phone')}
placeholder="1234567890"
/>
{errors.phone && (
<span className="error" role="alert">
{errors.phone.message}
</span>
)}
</div>
<div className="form-actions">
<button type="button" onClick={onBack} className="btn-secondary">
← Back
</button>
<button type="submit" className="btn-primary">
Next →
</button>
</div>
</form>
);
};
💡 Notice the Pattern
Each step component is remarkably similar:
- Receives
onNext,onBack(except first step), anddefaultValues - Uses
useFormwith its own schema and default values - Renders form fields with error handling
- Provides navigation buttons (Back + Next or Submit)
This consistency makes the code easy to understand and maintain!
🧭 Step Navigation and Validation
Navigation in multi-step forms needs to be intelligent. You can't just move between steps freely—you need to validate data, handle edge cases, and provide a smooth user experience. Let's explore advanced navigation patterns.
Validation Strategies
There are several approaches to validation in multi-step forms:
| Strategy | When to Validate | Pros | Cons |
|---|---|---|---|
| Validate on Next | When clicking "Next" button | Users can freely edit, no premature errors | Errors only shown when trying to proceed |
| Validate on Blur | When leaving each field | Immediate feedback per field | Can feel aggressive/annoying |
| Validate on Change | As user types | Real-time feedback | Very aggressive, can be distracting |
| Hybrid Approach | On Next initially, then on Change for errors | Best UX—forgiving at first, helpful after | More complex to implement |
✅ Recommended: Hybrid Approach
The best user experience comes from:
- First attempt: Validate only when user clicks "Next"
- After errors: Show real-time validation for fields that had errors
- Going back: Don't validate when going back (preserve data as-is)
React Hook Form supports this pattern natively with its mode option!
Implementing Smart Validation
const SmartValidationStep: React.FC<StepProps> = ({
onNext,
onBack,
defaultValues
}) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitted }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues,
mode: 'onSubmit', // Validate on first submit
reValidateMode: 'onChange' // Then validate on change
});
return (
<form onSubmit={handleSubmit(onNext)}>
{/* Form fields */}
<div className="form-actions">
<button
type="button"
onClick={onBack}
className="btn-secondary"
>
← Back
</button>
<button type="submit" className="btn-primary">
Next →
</button>
</div>
</form>
);
};
Preventing Navigation with Unsaved Changes
When users try to navigate away or close the browser with unsaved changes, warn them:
import { useEffect } from 'react';
import { useFormState } from 'react-hook-form';
const useWarnOnUnsavedChanges = (isDirty: boolean) => {
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault();
e.returnValue = ''; // Required for Chrome
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [isDirty]);
};
// Usage in step component
const StepWithWarning: React.FC<StepProps> = ({ onNext, onBack }) => {
const { control, handleSubmit } = useForm();
const { isDirty } = useFormState({ control });
// Warn if user tries to leave with unsaved changes
useWarnOnUnsavedChanges(isDirty);
return (
<form onSubmit={handleSubmit(onNext)}>
{/* Form fields */}
</form>
);
};
⚠️ Browser Limitations
The beforeunload event has limitations:
- You can't customize the warning message (browser shows generic message)
- It only works for browser navigation, not React Router navigation
- Some browsers ignore it in certain contexts
For in-app navigation, use React Router's navigation guards or a confirmation dialog.
Step Completion Tracking
Track which steps have been completed so users can jump back to any completed step:
interface MultiStepFormState {
currentStep: number;
completedSteps: Set<number>;
formData: any;
}
const MultiStepFormWithTracking: React.FC = () => {
const [state, setState] = useState<MultiStepFormState>({
currentStep: 1,
completedSteps: new Set(),
formData: {}
});
const handleStepComplete = (stepData: any) => {
setState(prev => ({
...prev,
completedSteps: new Set([...prev.completedSteps, prev.currentStep]),
formData: { ...prev.formData, ...stepData },
currentStep: prev.currentStep + 1
}));
};
const canNavigateToStep = (step: number): boolean => {
// Can navigate to current step or any completed step
return step === state.currentStep || state.completedSteps.has(step);
};
const goToStep = (step: number) => {
if (canNavigateToStep(step)) {
setState(prev => ({ ...prev, currentStep: step }));
}
};
return (
<div>
{/* Progress indicator with clickable steps */}
<div className="progress-steps">
{[1, 2, 3, 4].map(step => (
<button
key={step}
onClick={() => goToStep(step)}
disabled={!canNavigateToStep(step)}
className={`
step
${step === state.currentStep ? 'active' : ''}
${state.completedSteps.has(step) ? 'completed' : ''}
`}
aria-label={`Step ${step}${
state.completedSteps.has(step) ? ' (completed)' : ''
}${step === state.currentStep ? ' (current)' : ''}`}
>
{state.completedSteps.has(step) ? '✓' : step}
</button>
))}
</div>
{/* Render current step */}
{/* ... */}
</div>
);
};
Scroll to Top on Step Change
When navigating between steps, scroll to the top of the form:
import { useEffect, useRef } from 'react';
const MultiStepFormWithScroll: React.FC = () => {
const [currentStep, setCurrentStep] = useState(1);
const formRef = useRef<HTMLDivElement>(null);
// Scroll to top when step changes
useEffect(() => {
formRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}, [currentStep]);
return (
<div ref={formRef}>
{/* Form content */}
</div>
);
};
💡 Keyboard Navigation
Make your multi-step forms keyboard-friendly:
- Tab order: Ensure logical tab order through form fields
- Enter key: Pressing Enter should submit the current step (if valid)
- Escape key: Could cancel or go back (optional)
- Arrow keys: Navigate between steps in progress indicator (optional)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape' && currentStep > 1) {
handleBack();
}
};
📈 Progress Indicators
Progress indicators are crucial for multi-step forms. They show users where they are, how far they've come, and how much is left. Let's explore different progress indicator patterns and implement them properly.
Types of Progress Indicators
1. Numbered Steps
const NumberedProgress: React.FC<{
currentStep: number;
totalSteps: number;
completedSteps: Set<number>;
onStepClick?: (step: number) => void;
}> = ({ currentStep, totalSteps, completedSteps, onStepClick }) => {
return (
<div className="numbered-progress">
{Array.from({ length: totalSteps }, (_, i) => i + 1).map(step => {
const isCompleted = completedSteps.has(step);
const isCurrent = step === currentStep;
const isClickable = isCompleted || isCurrent;
return (
<div key={step} className="progress-step-container">
<button
onClick={() => isClickable && onStepClick?.(step)}
disabled={!isClickable}
className={`
progress-step
${isCurrent ? 'current' : ''}
${isCompleted ? 'completed' : ''}
`}
aria-current={isCurrent ? 'step' : undefined}
>
{isCompleted ? '✓' : step}
</button>
{step < totalSteps && (
<div
className={`progress-line ${
isCompleted ? 'completed' : ''
}`}
/>
)}
</div>
);
})}
</div>
);
};
2. Progress Bar
const ProgressBar: React.FC<{
currentStep: number;
totalSteps: number;
}> = ({ currentStep, totalSteps }) => {
const progress = ((currentStep - 1) / (totalSteps - 1)) * 100;
return (
<div className="progress-bar-container">
<div className="progress-info">
<span>Step {currentStep} of {totalSteps}</span>
<span>{Math.round(progress)}% Complete</span>
</div>
<div
className="progress-bar-track"
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Step ${currentStep} of ${totalSteps}`}
>
<div
className="progress-bar-fill"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
};
3. Step Labels
interface StepInfo {
number: number;
label: string;
description?: string;
}
const LabeledProgress: React.FC<{
steps: StepInfo[];
currentStep: number;
completedSteps: Set<number>;
}> = ({ steps, currentStep, completedSteps }) => {
return (
<div className="labeled-progress">
{steps.map(step => {
const isCompleted = completedSteps.has(step.number);
const isCurrent = step.number === currentStep;
return (
<div
key={step.number}
className={`
labeled-step
${isCurrent ? 'current' : ''}
${isCompleted ? 'completed' : ''}
`}
>
<div className="step-indicator">
{isCompleted ? '✓' : step.number}
</div>
<div className="step-content">
<div className="step-label">{step.label}</div>
{step.description && (
<div className="step-description">
{step.description}
</div>
)}
</div>
</div>
);
})}
</div>
);
};
// Usage
const steps: StepInfo[] = [
{ number: 1, label: 'Account', description: 'Create your account' },
{ number: 2, label: 'Profile', description: 'Tell us about yourself' },
{ number: 3, label: 'Preferences', description: 'Customize your experience' },
{ number: 4, label: 'Review', description: 'Confirm your information' }
];
Responsive Progress Indicators
On mobile devices, full step labels might not fit. Here's a responsive approach:
const ResponsiveProgress: React.FC<ProgressProps> = (props) => {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
if (isMobile) {
return <ProgressBar {...props} />;
}
return <LabeledProgress {...props} />;
};
✅ Progress Indicator Best Practices
- Always visible: Keep the progress indicator in view (sticky or fixed)
- Clear current step: Make it obvious which step the user is on
- Show completion: Visually distinguish completed steps
- Clickable when allowed: Let users return to completed steps
- Accessible: Use proper ARIA attributes for screen readers
- Mobile-friendly: Adapt to smaller screens
CSS for Progress Indicators
/* Numbered Progress Styles */
.numbered-progress {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2rem 0;
position: sticky;
top: 0;
background: white;
z-index: 100;
}
.progress-step-container {
display: flex;
align-items: center;
flex: 1;
}
.progress-step {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid #ccc;
background: white;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.progress-step.current {
border-color: #667eea;
background: #667eea;
color: white;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.2);
}
.progress-step.completed {
border-color: #4CAF50;
background: #4CAF50;
color: white;
}
.progress-step:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.progress-line {
flex: 1;
height: 2px;
background: #e0e0e0;
margin: 0 0.5rem;
transition: background 0.3s ease;
}
.progress-line.completed {
background: #4CAF50;
}
/* Progress Bar Styles */
.progress-bar-container {
padding: 1rem 0;
position: sticky;
top: 0;
background: white;
z-index: 100;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #666;
}
.progress-bar-track {
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
transition: width 0.3s ease;
border-radius: 4px;
}
/* Labeled Progress Styles */
.labeled-progress {
display: flex;
gap: 2rem;
padding: 2rem 0;
}
.labeled-step {
flex: 1;
display: flex;
gap: 1rem;
opacity: 0.5;
transition: opacity 0.3s ease;
}
.labeled-step.current,
.labeled-step.completed {
opacity: 1;
}
.step-indicator {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid #ccc;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
}
.labeled-step.current .step-indicator {
border-color: #667eea;
background: #667eea;
color: white;
}
.labeled-step.completed .step-indicator {
border-color: #4CAF50;
background: #4CAF50;
color: white;
}
.step-content {
flex: 1;
}
.step-label {
font-weight: 600;
margin-bottom: 0.25rem;
}
.step-description {
font-size: 0.875rem;
color: #666;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.labeled-progress {
flex-direction: column;
gap: 1rem;
}
.numbered-progress {
padding: 1rem 0;
}
.progress-step {
width: 32px;
height: 32px;
font-size: 0.875rem;
}
}
💡 Animation Tips
Smooth transitions make progress indicators feel polished:
- Animate the progress bar fill with CSS transitions
- Add a subtle scale animation when completing a step
- Use color transitions when changing step states
- Consider a celebratory animation when reaching 100%
.progress-step.completed {
animation: completePulse 0.6s ease;
}
@keyframes completePulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
🔀 Conditional Fields
Conditional fields show or hide based on the user's previous answers. This creates a dynamic, personalized form experience that only asks relevant questions. Think about forms that ask "Do you have pets?" and only show pet-related questions if you answer "Yes".
Basic Conditional Field Pattern
The simplest approach uses watch from React Hook Form to monitor field values:
import { useForm } from 'react-hook-form';
interface FormData {
hasPets: boolean;
petType?: string;
petName?: string;
}
const ConditionalFieldsBasic: React.FC = () => {
const { register, watch, handleSubmit } = useForm<FormData>({
defaultValues: {
hasPets: false
}
});
// Watch the hasPets field
const hasPets = watch('hasPets');
const onSubmit = (data: FormData) => {
console.log('Form data:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="form-group">
<label>
<input type="checkbox" {...register('hasPets')} />
Do you have pets?
</label>
</div>
{/* Conditional fields - only show if hasPets is true */}
{hasPets && (
<>
<div className="form-group">
<label htmlFor="petType">Type of Pet</label>
<select id="petType" {...register('petType')}>
<option value="">Select...</option>
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="bird">Bird</option>
<option value="other">Other</option>
</select>
</div>
<div className="form-group">
<label htmlFor="petName">Pet's Name</label>
<input id="petName" {...register('petName')} />
</div>
</>
)}
<button type="submit">Submit</button>
</form>
);
};
✅ How watch() Works
The watch() function from React Hook Form:
- Returns the current value of a field (or all fields if no name provided)
- Re-renders the component when the watched field changes
- Is more efficient than subscribing to form state manually
- Can watch multiple fields:
const [field1, field2] = watch(['field1', 'field2'])
Conditional Validation
When fields are conditional, their validation should be conditional too. Use Zod's refine or conditional schemas:
import { z } from 'zod';
// Approach 1: Using refine
const conditionalSchema = z.object({
hasPets: z.boolean(),
petType: z.string().optional(),
petName: z.string().optional()
}).refine(
(data) => {
// If hasPets is true, petType and petName are required
if (data.hasPets) {
return data.petType && data.petName;
}
return true;
},
{
message: 'Pet type and name are required when you have pets',
path: ['petType'] // Which field to attach error to
}
);
// Approach 2: Conditional schema with discriminated union
const schemaWithPets = z.object({
hasPets: z.literal(true),
petType: z.enum(['dog', 'cat', 'bird', 'other']),
petName: z.string().min(1, 'Pet name is required')
});
const schemaWithoutPets = z.object({
hasPets: z.literal(false)
});
const betterConditionalSchema = z.discriminatedUnion('hasPets', [
schemaWithPets,
schemaWithoutPets
]);
// Usage
const ConditionalValidation: React.FC = () => {
const {
register,
watch,
handleSubmit,
formState: { errors }
} = useForm<FormData>({
resolver: zodResolver(conditionalSchema)
});
const hasPets = watch('hasPets');
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
{hasPets && (
<div className="form-group">
<label htmlFor="petType">Type of Pet *</label>
<select id="petType" {...register('petType')}>
<option value="">Select...</option>
<option value="dog">Dog</option>
<option value="cat">Cat</option>
</select>
{errors.petType && (
<span className="error">{errors.petType.message}</span>
)}
</div>
)}
</form>
);
};
Multiple Conditional Levels
Sometimes conditionals are nested—field B appears if field A is true, and field C appears if field B has a certain value:
interface ShippingFormData {
shippingMethod: 'standard' | 'express' | 'pickup';
address?: string;
expressDate?: string;
pickupLocation?: string;
}
const NestedConditionals: React.FC = () => {
const { register, watch } = useForm<ShippingFormData>();
const shippingMethod = watch('shippingMethod');
return (
<form>
<div className="form-group">
<label>Shipping Method</label>
<select {...register('shippingMethod')}>
<option value="standard">Standard Shipping</option>
<option value="express">Express Shipping</option>
<option value="pickup">Store Pickup</option>
</select>
</div>
{/* Show address for standard or express */}
{(shippingMethod === 'standard' || shippingMethod === 'express') && (
<div className="form-group">
<label>Shipping Address</label>
<input {...register('address')} />
</div>
)}
{/* Show date picker only for express */}
{shippingMethod === 'express' && (
<div className="form-group">
<label>Preferred Delivery Date</label>
<input type="date" {...register('expressDate')} />
</div>
)}
{/* Show pickup location only for pickup */}
{shippingMethod === 'pickup' && (
<div className="form-group">
<label>Pickup Location</label>
<select {...register('pickupLocation')}>
<option>Downtown Store</option>
<option>Mall Location</option>
<option>Airport Store</option>
</select>
</div>
)}
</form>
);
};
⚠️ Performance Consideration
Each watch() call causes a re-render when the watched field changes. For forms with many conditional fields:
- Watch only the fields you need
- Consider watching all fields once:
const values = watch() - Use
useWatchhook for better performance in complex scenarios - Memoize expensive conditional calculations
Animated Conditional Fields
Make conditional fields appear smoothly with CSS transitions:
import { useState, useEffect } from 'react';
const AnimatedConditionalField: React.FC<{
show: boolean;
children: React.ReactNode;
}> = ({ show, children }) => {
const [shouldRender, setShouldRender] = useState(show);
useEffect(() => {
if (show) setShouldRender(true);
}, [show]);
const handleAnimationEnd = () => {
if (!show) setShouldRender(false);
};
if (!shouldRender) return null;
return (
<div
className={`conditional-field ${show ? 'show' : 'hide'}`}
onAnimationEnd={handleAnimationEnd}
>
{children}
</div>
);
};
// CSS
/*
.conditional-field {
animation: slideDown 0.3s ease;
overflow: hidden;
}
.conditional-field.hide {
animation: slideUp 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
max-height: 500px;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 1;
max-height: 500px;
transform: translateY(0);
}
to {
opacity: 0;
max-height: 0;
transform: translateY(-10px);
}
}
*/
💡 UX Best Practices for Conditional Fields
- Clear Cause-and-Effect: Make it obvious why fields appear/disappear
- Smooth Transitions: Don't just show/hide—animate the change
- Preserve Scroll Position: Don't jump the page when fields appear
- Don't Lose Data: If user fills conditional field, changes trigger, then changes back—restore their data
- Visual Grouping: Group conditional fields visually (border, background, indentation)
- Accessibility: Announce changes to screen readers with ARIA live regions
⚡ Interactive: Conditional Fields in Action
Experience how conditional fields work by interacting with this simulation. Notice how watch() tracks the employment status and dynamically shows or hides related fields:
status === 'employed'
status === 'self-employed'
status === 'student'
✅ What This Demonstrates
- watch() Reactivity: The
watch()function returns the current value and triggers re-renders when it changes - Conditional Rendering: Simple JavaScript comparison determines which JSX to render
- Smooth Transitions: CSS animations make the show/hide feel natural
- Visual Grouping: Conditional fields are visually distinguished with borders and background colors
🧩 Advanced Conditional Logic
Real-world forms often have complex conditional logic involving multiple fields, calculations, and business rules. Let's explore patterns for handling sophisticated conditional scenarios.
Boolean Logic Combinations
Sometimes fields should appear based on complex AND/OR conditions:
interface ApplicationFormData {
applicationType: 'individual' | 'business' | 'nonprofit';
annualRevenue: number;
employeeCount: number;
isInternational: boolean;
}
const ComplexConditionalForm: React.FC = () => {
const { register, watch } = useForm<ApplicationFormData>();
const applicationType = watch('applicationType');
const annualRevenue = watch('annualRevenue');
const employeeCount = watch('employeeCount');
const isInternational = watch('isInternational');
// Complex condition: Show tax ID field if...
const shouldShowTaxId =
applicationType === 'business' ||
applicationType === 'nonprofit' ||
(applicationType === 'individual' && annualRevenue > 50000);
// Show international fields if applicable
const shouldShowInternationalFields =
isInternational && (
applicationType === 'business' ||
employeeCount > 10
);
// Show certification field for large nonprofits
const shouldShowCertification =
applicationType === 'nonprofit' &&
annualRevenue > 100000;
return (
<form>
<div className="form-group">
<label>Application Type</label>
<select {...register('applicationType')}>
<option value="individual">Individual</option>
<option value="business">Business</option>
<option value="nonprofit">Non-Profit</option>
</select>
</div>
<div className="form-group">
<label>Annual Revenue</label>
<input
type="number"
{...register('annualRevenue', { valueAsNumber: true })}
/>
</div>
{shouldShowTaxId && (
<div className="form-group conditional-section">
<label>Tax ID Number *</label>
<input {...register('taxId')} />
<small>Required for businesses and high-revenue individuals</small>
</div>
)}
<div className="form-group">
<label>
<input type="checkbox" {...register('isInternational')} />
International Operations
</label>
</div>
{shouldShowInternationalFields && (
<div className="conditional-section">
<h3>International Information</h3>
<div className="form-group">
<label>Primary Country of Operation</label>
<input {...register('primaryCountry')} />
</div>
<div className="form-group">
<label>International Tax ID</label>
<input {...register('internationalTaxId')} />
</div>
</div>
)}
{shouldShowCertification && (
<div className="form-group conditional-section">
<label>Non-Profit Certification Number *</label>
<input {...register('certificationNumber')} />
<small>Required for non-profits with revenue over $100,000</small>
</div>
)}
</form>
);
};
✅ Clean Conditional Logic
Keep your conditional logic maintainable:
- Extract to Variables: Give complex conditions descriptive names
- Document Business Rules: Comment why conditions exist
- Centralize Logic: Consider a custom hook for complex form rules
- Test Thoroughly: Complex conditionals need thorough testing
Custom Hook for Conditional Logic
For very complex forms, extract conditional logic into a custom hook:
interface FormRules {
shouldShowTaxId: boolean;
shouldShowInternational: boolean;
shouldShowCertification: boolean;
shouldShowPaymentPlan: boolean;
}
const useFormConditionalRules = (
formValues: ApplicationFormData
): FormRules => {
const {
applicationType,
annualRevenue,
employeeCount,
isInternational
} = formValues;
// Calculate all conditional rules
const rules: FormRules = useMemo(() => ({
shouldShowTaxId:
applicationType === 'business' ||
applicationType === 'nonprofit' ||
(applicationType === 'individual' && annualRevenue > 50000),
shouldShowInternational:
isInternational && (
applicationType === 'business' ||
employeeCount > 10
),
shouldShowCertification:
applicationType === 'nonprofit' &&
annualRevenue > 100000,
shouldShowPaymentPlan:
annualRevenue > 0 && annualRevenue < 25000
}), [applicationType, annualRevenue, employeeCount, isInternational]);
return rules;
};
// Usage in component
const FormWithRulesHook: React.FC = () => {
const { register, watch } = useForm<ApplicationFormData>();
const formValues = watch();
// Get all conditional rules from custom hook
const rules = useFormConditionalRules(formValues);
return (
<form>
{/* Base fields */}
{rules.shouldShowTaxId && (
<div>{/* Tax ID field */}</div>
)}
{rules.shouldShowInternational && (
<div>{/* International fields */}</div>
)}
{rules.shouldShowCertification && (
<div>{/* Certification field */}</div>
)}
{rules.shouldShowPaymentPlan && (
<div>{/* Payment plan options */}</div>
)}
</form>
);
};
Conditional Entire Steps
In multi-step forms, entire steps might be conditional:
const MultiStepWithConditionalSteps: React.FC = () => {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<any>({});
// Determine which steps to show based on data
const getSteps = () => {
const baseSteps = [
{ number: 1, component: AccountInfoStep, label: 'Account' },
{ number: 2, component: ShippingStep, label: 'Shipping' }
];
// Add billing step only if shipping !== billing
if (!formData.sameAsBilling) {
baseSteps.push({
number: 3,
component: BillingStep,
label: 'Billing'
});
}
// Payment step (number depends on whether billing was shown)
const paymentStepNumber = formData.sameAsBilling ? 3 : 4;
baseSteps.push({
number: paymentStepNumber,
component: PaymentStep,
label: 'Payment'
});
// Review is always last
baseSteps.push({
number: paymentStepNumber + 1,
component: ReviewStep,
label: 'Review'
});
return baseSteps;
};
const steps = getSteps();
const totalSteps = steps.length;
const currentStepData = steps.find(s => s.number === currentStep);
const handleNext = (stepData: any) => {
setFormData(prev => ({ ...prev, ...stepData }));
if (currentStep < totalSteps) {
setCurrentStep(prev => prev + 1);
}
};
return (
<div>
<ProgressBar currentStep={currentStep} totalSteps={totalSteps} />
{currentStepData && (
<currentStepData.component
onNext={handleNext}
onBack={() => setCurrentStep(prev => prev - 1)}
defaultValues={formData}
/>
)}
</div>
);
};
💡 Dynamic Step Numbers
When steps are conditional, step numbers change dynamically. Consider:
- Recalculate step numbers when formData changes
- Update progress indicators to reflect actual total steps
- Adjust navigation logic to skip over hidden steps
- Clear data from skipped steps if those fields are no longer relevant
🔗 Field Dependencies
Field dependencies occur when one field's value affects or determines another field's options, validation, or value. Common examples include country → state → city cascading dropdowns, or automatic calculations based on other fields.
Cascading Dropdowns
The classic example: selecting a country determines which states are available, and selecting a state determines which cities appear:
import { useEffect } from 'react';
import { useForm, useWatch } from 'react-hook-form';
interface LocationData {
country: string;
state: string;
city: string;
}
// Mock data - in real app, this would come from an API
const locationData = {
USA: {
California: ['Los Angeles', 'San Francisco', 'San Diego'],
Texas: ['Houston', 'Dallas', 'Austin'],
NewYork: ['New York City', 'Buffalo', 'Rochester']
},
Canada: {
Ontario: ['Toronto', 'Ottawa', 'Mississauga'],
Quebec: ['Montreal', 'Quebec City', 'Laval'],
BritishColumbia: ['Vancouver', 'Victoria', 'Surrey']
}
};
const CascadingDropdowns: React.FC = () => {
const { register, watch, setValue, resetField } = useForm<LocationData>({
defaultValues: {
country: '',
state: '',
city: ''
}
});
const selectedCountry = watch('country');
const selectedState = watch('state');
// Get available states based on selected country
const availableStates = selectedCountry
? Object.keys(locationData[selectedCountry as keyof typeof locationData] || {})
: [];
// Get available cities based on selected state
const availableCities = selectedCountry && selectedState
? locationData[selectedCountry as keyof typeof locationData]?.[selectedState] || []
: [];
// Reset dependent fields when parent changes
useEffect(() => {
if (selectedCountry) {
// Country changed - reset state and city
resetField('state');
resetField('city');
}
}, [selectedCountry, resetField]);
useEffect(() => {
if (selectedState) {
// State changed - reset city
resetField('city');
}
}, [selectedState, resetField]);
return (
<form>
<div className="form-group">
<label htmlFor="country">Country *</label>
<select id="country" {...register('country', { required: true })}>
<option value="">Select a country</option>
<option value="USA">United States</option>
<option value="Canada">Canada</option>
</select>
</div>
<div className="form-group">
<label htmlFor="state">State/Province *</label>
<select
id="state"
{...register('state', { required: true })}
disabled={!selectedCountry}
>
<option value="">
{selectedCountry ? 'Select a state' : 'Select a country first'}
</option>
{availableStates.map(state => (
<option key={state} value={state}>
{state}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="city">City *</label>
<select
id="city"
{...register('city', { required: true })}
disabled={!selectedState}
>
<option value="">
{selectedState ? 'Select a city' : 'Select a state first'}
</option>
{availableCities.map(city => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
</div>
</form>
);
};
✅ Key Patterns for Cascading Dropdowns
- Reset Dependent Fields: Use
resetField()when parent changes - Disable Children: Disable dependent fields until parent is selected
- Clear Messages: Tell users why a field is disabled
- Preserve Valid Selections: Only reset if the selected value is no longer valid
Loading Dependent Data from API
In real applications, dependent data often comes from an API:
const DynamicCascadingDropdowns: React.FC = () => {
const { register, watch, resetField } = useForm<LocationData>();
const [states, setStates] = useState<string[]>([]);
const [cities, setCities] = useState<string[]>([]);
const [loadingStates, setLoadingStates] = useState(false);
const [loadingCities, setLoadingCities] = useState(false);
const selectedCountry = watch('country');
const selectedState = watch('state');
// Load states when country changes
useEffect(() => {
if (!selectedCountry) {
setStates([]);
return;
}
const loadStates = async () => {
setLoadingStates(true);
try {
const response = await fetch(`/api/states?country=${selectedCountry}`);
const data = await response.json();
setStates(data);
} catch (error) {
console.error('Failed to load states:', error);
setStates([]);
} finally {
setLoadingStates(false);
}
};
loadStates();
resetField('state');
setCities([]);
resetField('city');
}, [selectedCountry, resetField]);
// Load cities when state changes
useEffect(() => {
if (!selectedState) {
setCities([]);
return;
}
const loadCities = async () => {
setLoadingCities(true);
try {
const response = await fetch(
`/api/cities?country=${selectedCountry}&state=${selectedState}`
);
const data = await response.json();
setCities(data);
} catch (error) {
console.error('Failed to load cities:', error);
setCities([]);
} finally {
setLoadingCities(false);
}
};
loadCities();
resetField('city');
}, [selectedState, selectedCountry, resetField]);
return (
<form>
<div className="form-group">
<label htmlFor="country">Country *</label>
<select id="country" {...register('country')}>
<option value="">Select a country</option>
<option value="USA">United States</option>
<option value="Canada">Canada</option>
</select>
</div>
<div className="form-group">
<label htmlFor="state">State/Province *</label>
<select
id="state"
{...register('state')}
disabled={!selectedCountry || loadingStates}
>
<option value="">
{loadingStates ? 'Loading...' : 'Select a state'}
</option>
{states.map(state => (
<option key={state} value={state}>{state}</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="city">City *</label>
<select
id="city"
{...register('city')}
disabled={!selectedState || loadingCities}
>
<option value="">
{loadingCities ? 'Loading...' : 'Select a city'}
</option>
{cities.map(city => (
<option key={city} value={city}>{city}</option>
))}
</select>
</div>
</form>
);
};
Calculated Fields
Some fields automatically calculate based on other fields:
interface InvoiceData {
quantity: number;
unitPrice: number;
subtotal: number;
taxRate: number;
taxAmount: number;
total: number;
discount: number;
}
const CalculatedFieldsForm: React.FC = () => {
const { register, watch, setValue } = useForm<InvoiceData>({
defaultValues: {
quantity: 1,
unitPrice: 0,
taxRate: 10, // 10%
discount: 0
}
});
const quantity = watch('quantity');
const unitPrice = watch('unitPrice');
const taxRate = watch('taxRate');
const discount = watch('discount');
// Calculate derived values
useEffect(() => {
const subtotal = quantity * unitPrice;
const discountAmount = (subtotal * discount) / 100;
const subtotalAfterDiscount = subtotal - discountAmount;
const taxAmount = (subtotalAfterDiscount * taxRate) / 100;
const total = subtotalAfterDiscount + taxAmount;
setValue('subtotal', subtotal);
setValue('taxAmount', taxAmount);
setValue('total', total);
}, [quantity, unitPrice, taxRate, discount, setValue]);
return (
<form>
<div className="form-row">
<div className="form-group">
<label htmlFor="quantity">Quantity</label>
<input
id="quantity"
type="number"
min="1"
{...register('quantity', { valueAsNumber: true })}
/>
</div>
<div className="form-group">
<label htmlFor="unitPrice">Unit Price</label>
<input
id="unitPrice"
type="number"
min="0"
step="0.01"
{...register('unitPrice', { valueAsNumber: true })}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="discount">Discount (%)</label>
<input
id="discount"
type="number"
min="0"
max="100"
{...register('discount', { valueAsNumber: true })}
/>
</div>
<div className="form-group">
<label htmlFor="taxRate">Tax Rate (%)</label>
<input
id="taxRate"
type="number"
min="0"
max="100"
step="0.1"
{...register('taxRate', { valueAsNumber: true })}
/>
</div>
{/* Read-only calculated fields */}
<div className="calculated-fields">
<div className="form-group">
<label>Subtotal</label>
<input
{...register('subtotal')}
readOnly
className="calculated"
/>
</div>
<div className="form-group">
<label>Tax Amount</label>
<input
{...register('taxAmount')}
readOnly
className="calculated"
/>
</div>
<div className="form-group">
<label>Total</label>
<input
{...register('total')}
readOnly
className="calculated total"
/>
</div>
</div>
</form>
);
};
💡 Formatting Calculated Values
Display calculated values in user-friendly formats:
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value);
};
// Display formatted value
<div className="calculated-display">
<span>Total: </span>
<strong>{formatCurrency(watch('total'))}</strong>
</div>
Field Dependency Hook
Extract complex field dependency logic into a reusable hook:
interface DependencyConfig<T> {
sourceField: keyof T;
targetField: keyof T;
calculate: (sourceValue: any) => any;
}
const useFieldDependency = <T extends Record<string, any>>(
control: Control<T>,
dependencies: DependencyConfig<T>[]
) => {
const { setValue } = useFormContext<T>();
dependencies.forEach(({ sourceField, targetField, calculate }) => {
const sourceValue = useWatch({
control,
name: sourceField as string
});
useEffect(() => {
if (sourceValue !== undefined) {
const calculatedValue = calculate(sourceValue);
setValue(targetField as string, calculatedValue);
}
}, [sourceValue, targetField, calculate, setValue]);
});
};
// Usage
const FormWithDependencies: React.FC = () => {
const { control } = useForm<InvoiceData>();
useFieldDependency(control, [
{
sourceField: 'quantity',
targetField: 'subtotal',
calculate: (qty) => qty * watch('unitPrice')
},
// More dependencies...
]);
return <form>{/* ... */}</form>;
};
⚠️ Performance with Dependencies
Be mindful of performance when dealing with field dependencies:
- Avoid Circular Dependencies: Field A → Field B → Field A creates infinite loops
- Debounce Expensive Calculations: Don't recalculate on every keystroke
- Memoize Complex Calculations: Use
useMemofor expensive operations - Batch Updates: Update multiple dependent fields together
💾 Auto-Save Implementation
Auto-save is a critical feature for long forms. It prevents data loss if users accidentally close the browser, lose connection, or navigate away. Let's implement robust auto-save functionality.
Basic Auto-Save to localStorage
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
const STORAGE_KEY = 'form-draft';
const AUTO_SAVE_DELAY = 2000; // 2 seconds
const AutoSaveForm: React.FC = () => {
const { register, watch, reset } = useForm({
defaultValues: () => {
// Load saved data on mount
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : {};
}
});
const formData = watch();
// Auto-save to localStorage
useEffect(() => {
const timer = setTimeout(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(formData));
console.log('Form auto-saved');
}, AUTO_SAVE_DELAY);
return () => clearTimeout(timer);
}, [formData]);
const handleSubmit = (data: any) => {
console.log('Submitting:', data);
// Clear saved draft on successful submit
localStorage.removeItem(STORAGE_KEY);
};
const clearDraft = () => {
localStorage.removeItem(STORAGE_KEY);
reset({});
};
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<div className="form-actions">
<button type="button" onClick={clearDraft}>
Clear Draft
</button>
<button type="submit">
Submit
</button>
</div>
</form>
);
};
✅ How This Auto-Save Works
- Load on Mount: Check localStorage for saved draft when component mounts
- Watch All Fields:
watch()without arguments watches all form fields - Debounced Save: Use
setTimeoutto save after user stops typing - Clear on Submit: Remove draft after successful submission
Auto-Save with Visual Feedback
Show users when their work is being saved:
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
const AutoSaveWithFeedback: React.FC = () => {
const { register, watch, formState } = useForm();
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const formData = watch();
useEffect(() => {
if (formState.isDirty) {
setSaveStatus('saving');
const timer = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(formData));
setSaveStatus('saved');
setLastSaved(new Date());
// Reset to idle after 2 seconds
setTimeout(() => setSaveStatus('idle'), 2000);
} catch (error) {
console.error('Auto-save failed:', error);
setSaveStatus('error');
}
}, AUTO_SAVE_DELAY);
return () => clearTimeout(timer);
}
}, [formData, formState.isDirty]);
const formatLastSaved = () => {
if (!lastSaved) return '';
const seconds = Math.floor((Date.now() - lastSaved.getTime()) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
return lastSaved.toLocaleTimeString();
};
return (
<div>
{/* Save status indicator */}
<div className={`save-status ${saveStatus}`}>
{saveStatus === 'saving' && (
<span>💾 Saving...</span>
)}
{saveStatus === 'saved' && (
<span>✓ Saved {formatLastSaved()}</span>
)}
{saveStatus === 'error' && (
<span>⚠️ Failed to save</span>
)}
</div>
<form>
{/* Form fields */}
</form>
</div>
);
};
Auto-Save Hook
Create a reusable auto-save hook:
interface UseAutoSaveOptions {
key: string;
delay?: number;
onSave?: (data: any) => void | Promise<void>;
onError?: (error: Error) => void;
}
const useAutoSave = <T extends Record<string, any>>(
formData: T,
options: UseAutoSaveOptions
) => {
const { key, delay = 2000, onSave, onError } = options;
const [status, setStatus] = useState<SaveStatus>('idle');
const [lastSaved, setLastSaved] = useState<Date | null>(null);
useEffect(() => {
setStatus('saving');
const timer = setTimeout(async () => {
try {
// Save to localStorage
localStorage.setItem(key, JSON.stringify(formData));
// Call custom save function if provided
if (onSave) {
await onSave(formData);
}
setStatus('saved');
setLastSaved(new Date());
// Reset to idle after showing "saved" message
setTimeout(() => setStatus('idle'), 2000);
} catch (error) {
console.error('Auto-save failed:', error);
setStatus('error');
if (onError && error instanceof Error) {
onError(error);
}
}
}, delay);
return () => clearTimeout(timer);
}, [formData, key, delay, onSave, onError]);
const clearSaved = () => {
localStorage.removeItem(key);
setStatus('idle');
setLastSaved(null);
};
return { status, lastSaved, clearSaved };
};
// Usage
const FormWithAutoSaveHook: React.FC = () => {
const { register, watch } = useForm();
const formData = watch();
const { status, lastSaved } = useAutoSave(formData, {
key: 'my-form-draft',
delay: 2000,
onSave: async (data) => {
// Optional: save to server
await fetch('/api/drafts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
},
onError: (error) => {
console.error('Save failed:', error);
alert('Failed to save draft');
}
});
return (
<div>
<SaveStatusIndicator status={status} lastSaved={lastSaved} />
<form>{/* Form fields */}</form>
</div>
);
};
Auto-Save to Server
For critical data, save to your server instead of (or in addition to) localStorage:
const useServerAutoSave = <T,>(
formData: T,
draftId: string | null,
options: {
delay?: number;
endpoint?: string;
} = {}
) => {
const { delay = 3000, endpoint = '/api/drafts' } = options;
const [status, setStatus] = useState<SaveStatus>('idle');
const [savedDraftId, setSavedDraftId] = useState<string | null>(draftId);
useEffect(() => {
setStatus('saving');
const timer = setTimeout(async () => {
try {
const url = savedDraftId
? `${endpoint}/${savedDraftId}`
: endpoint;
const method = savedDraftId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (!response.ok) {
throw new Error('Server save failed');
}
const result = await response.json();
// Save draft ID for future updates
if (!savedDraftId && result.id) {
setSavedDraftId(result.id);
}
setStatus('saved');
setTimeout(() => setStatus('idle'), 2000);
} catch (error) {
console.error('Server auto-save failed:', error);
setStatus('error');
}
}, delay);
return () => clearTimeout(timer);
}, [formData, savedDraftId, endpoint, delay]);
return { status, draftId: savedDraftId };
};
💡 Auto-Save Best Practices
- Debounce: Don't save on every keystroke—wait for user to pause
- Show Status: Always indicate saving/saved/error state
- Handle Errors: Gracefully handle save failures, offer retry
- Clear on Submit: Remove drafts after successful submission
- Offline Support: Use localStorage as fallback when server is unavailable
- Version Conflicts: Handle cases where same form is open in multiple tabs
- Privacy: Clear sensitive data from localStorage after session ends
⚠️ localStorage Limitations
- Size Limit: Usually 5-10MB per domain
- Security: Data is not encrypted, accessible via JavaScript
- Synchronous: Can block the main thread with large data
- Browser-Specific: Not shared across browsers or devices
- Incognito Mode: Cleared when closing incognito window
For large or sensitive data, prefer server-side saving!
⚡ Interactive: Auto-Save Debounce Timing
This visualization demonstrates how debouncing prevents excessive save operations. Type in the input field and watch how multiple rapid keystrokes get coalesced into a single save:
✅ What This Demonstrates
- Debounce Efficiency: Notice how many keystrokes result in a single save operation
- Timer Reset: Each keystroke resets the wait timer, preventing premature saves
- Adjustable Delay: Longer delays = fewer saves but more latency; find the right balance
- User Feedback: Clear status indicators show the user what's happening
🧙 Form Wizards
A form wizard is a specialized multi-step form that guides users through a specific process with clear instructions at each step. Wizards are great for complex workflows like product configuration, account setup, or onboarding.
Wizard vs. Regular Multi-Step Form
| Aspect | Multi-Step Form | Form Wizard |
|---|---|---|
| Purpose | Data collection | Guided process |
| Navigation | Linear, can go back | Linear, sometimes can't skip ahead |
| Instructions | Minimal | Rich guidance at each step |
| Steps | Usually 2-5 steps | Can be 5-10+ steps |
| Complexity | Simple to moderate | Moderate to high |
Complete Wizard Implementation
interface WizardStep {
id: string;
title: string;
description: string;
component: React.ComponentType<StepComponentProps>;
canSkip?: boolean;
icon?: string;
}
interface WizardContextValue {
currentStepIndex: number;
steps: WizardStep[];
formData: any;
goToStep: (index: number) => void;
nextStep: (data: any) => void;
previousStep: () => void;
canGoToStep: (index: number) => boolean;
isFirstStep: boolean;
isLastStep: boolean;
completedSteps: Set<number>;
}
const WizardContext = createContext<WizardContextValue | null>(null);
export const useWizard = () => {
const context = useContext(WizardContext);
if (!context) {
throw new Error('useWizard must be used within WizardProvider');
}
return context;
};
const WizardProvider: React.FC<{
steps: WizardStep[];
onComplete: (data: any) => void;
children: React.ReactNode;
}> = ({ steps, onComplete, children }) => {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [formData, setFormData] = useState<any>({});
const [completedSteps, setCompletedSteps] = useState<Set<number>>(
new Set()
);
const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === steps.length - 1;
const canGoToStep = (index: number): boolean => {
// Can go to current step or any completed step
if (index === currentStepIndex) return true;
if (completedSteps.has(index)) return true;
// Can go to next step if current is completed
if (index === currentStepIndex + 1 && completedSteps.has(currentStepIndex)) {
return true;
}
return false;
};
const goToStep = (index: number) => {
if (canGoToStep(index)) {
setCurrentStepIndex(index);
}
};
const nextStep = (stepData: any) => {
// Save step data
const updatedFormData = { ...formData, ...stepData };
setFormData(updatedFormData);
// Mark current step as completed
setCompletedSteps(prev => new Set([...prev, currentStepIndex]));
if (isLastStep) {
// Submit complete form
onComplete(updatedFormData);
} else {
// Move to next step
setCurrentStepIndex(prev => prev + 1);
}
};
const previousStep = () => {
if (!isFirstStep) {
setCurrentStepIndex(prev => prev - 1);
}
};
const value: WizardContextValue = {
currentStepIndex,
steps,
formData,
goToStep,
nextStep,
previousStep,
canGoToStep,
isFirstStep,
isLastStep,
completedSteps
};
return (
<WizardContext.Provider value={value}>
{children}
</WizardContext.Provider>
);
};
// Main Wizard Component
const Wizard: React.FC<{
steps: WizardStep[];
onComplete: (data: any) => void;
}> = ({ steps, onComplete }) => {
return (
<WizardProvider steps={steps} onComplete={onComplete}>
<WizardContainer />
</WizardProvider>
);
};
const WizardContainer: React.FC = () => {
const {
currentStepIndex,
steps,
formData,
completedSteps,
goToStep,
canGoToStep
} = useWizard();
const currentStep = steps[currentStepIndex];
const StepComponent = currentStep.component;
return (
<div className="wizard-container">
{/* Wizard Header */}
<div className="wizard-header">
<h1>Setup Wizard</h1>
<p>Complete the steps below to finish setup</p>
</div>
{/* Step Progress */}
<div className="wizard-progress">
{steps.map((step, index) => (
<button
key={step.id}
onClick={() => goToStep(index)}
disabled={!canGoToStep(index)}
className={`
wizard-step
${index === currentStepIndex ? 'active' : ''}
${completedSteps.has(index) ? 'completed' : ''}
`}
>
<div className="step-number">
{completedSteps.has(index) ? '✓' : index + 1}
</div>
<div className="step-info">
<div className="step-title">{step.title}</div>
<div className="step-description">{step.description}</div>
</div>
</button>
))}
</div>
{/* Current Step Content */}
<div className="wizard-content">
<div className="step-header">
{currentStep.icon && (
<span className="step-icon">{currentStep.icon}</span>
)}
<h2>{currentStep.title}</h2>
<p>{currentStep.description}</p>
</div>
<StepComponent defaultValues={formData} />
</div>
</div>
);
};
Step Component Interface
interface StepComponentProps {
defaultValues?: any;
}
// Example step component
const AccountSetupStep: React.FC<StepComponentProps> = ({ defaultValues }) => {
const { nextStep, previousStep, isFirstStep } = useWizard();
const {
register,
handleSubmit,
formState: { errors }
} = useForm({
defaultValues
});
const onSubmit = (data: any) => {
nextStep(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
<div className="wizard-actions">
{!isFirstStep && (
<button type="button" onClick={previousStep}>
← Back
</button>
)}
<button type="submit">
Continue →
</button>
</div>
</form>
);
};
✅ Wizard Best Practices
- Clear Progress: Show exactly where users are in the process
- Skip Options: Allow skipping optional steps
- Save Progress: Auto-save so users can return later
- Review Step: Include a final review before submission
- Help Text: Provide guidance at each step
- Validation: Validate each step before proceeding
- Exit Warning: Warn users if they try to leave incomplete wizard
Wizard with Review Step
const ReviewStep: React.FC<StepComponentProps> = () => {
const { formData, previousStep, nextStep, goToStep, steps } = useWizard();
const handleSubmit = () => {
nextStep({}); // Triggers onComplete in WizardProvider
};
return (
<div className="review-step">
<h2>Review Your Information</h2>
<p>Please review your information before submitting.</p>
{/* Display all collected data */}
{steps.slice(0, -1).map((step, index) => {
const stepData = formData[step.id] || {};
const hasData = Object.keys(stepData).length > 0;
if (!hasData) return null;
return (
<div key={step.id} className="review-section">
<div className="review-section-header">
<h3>{step.title}</h3>
<button
type="button"
onClick={() => goToStep(index)}
className="edit-button"
>
Edit
</button>
</div>
<dl className="review-data">
{Object.entries(stepData).map(([key, value]) => (
<div key={key} className="review-item">
<dt>{formatFieldName(key)}</dt>
<dd>{String(value)}</dd>
</div>
))}
</dl>
</div>
);
})}
<div className="wizard-actions">
<button type="button" onClick={previousStep}>
← Back
</button>
<button
type="button"
onClick={handleSubmit}
className="btn-primary"
>
Submit
</button>
</div>
</div>
);
};
// Helper function
const formatFieldName = (key: string): string => {
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.trim();
};
💡 Wizard CSS Tips
/* Wizard container */
.wizard-container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
/* Progress steps */
.wizard-progress {
display: flex;
gap: 1rem;
margin: 2rem 0;
overflow-x: auto;
}
.wizard-step {
flex: 1;
min-width: 200px;
padding: 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.3s ease;
}
.wizard-step.active {
border-color: #667eea;
background: #f0f4ff;
}
.wizard-step.completed {
border-color: #4CAF50;
}
.wizard-step:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Review step */
.review-section {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.review-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.review-data {
display: grid;
grid-template-columns: 150px 1fr;
gap: 0.75rem;
}
.review-item dt {
font-weight: 600;
color: #666;
}
.review-item dd {
margin: 0;
}
➕ Dynamic Field Arrays with useFieldArray
So far every field we've built has been fixed: the form knows in advance that it needs an email, a password, a country. But plenty of real forms let the user decide how many of something to enter — invoice line items, a list of guests, phone numbers, work-experience entries on a résumé. The user might add three, or ten, or delete one from the middle. This is a dynamic field array, and it's one of the places where hand-rolled state gets painful fast.
Think of it like a spreadsheet where the user keeps clicking "add row." You need a stable identity for each row (so React doesn't scramble inputs when one is removed), typed access to every cell, and validation that runs per row and on the array as a whole. React Hook Form's useFieldArray hook handles all of that for you.
📖 Definition
useFieldArray: a React Hook Form hook that manages a list of repeatable field groups. It gives you a fields array to render and typed helpers — append, prepend, insert, remove, move, swap, update, replace — to mutate the list without ever touching component state yourself.
A Typed Line-Item Editor
Let's build the classic example: an order form with a variable number of line items. Notice how the Zod schema describes a single item and then an array of them, and how TypeScript infers the whole shape from the schema — one source of truth.
import { useForm, useFieldArray, type SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Describe ONE item, then an array of them.
const lineItemSchema = z.object({
description: z.string().min(1, 'Description is required'),
// z.coerce.number() turns the input's string value into a number for you.
quantity: z.coerce.number().int().positive('Must be at least 1'),
unitPrice: z.coerce.number().nonnegative('Cannot be negative'),
});
const orderSchema = z.object({
customer: z.string().min(1, 'Customer name is required'),
items: z.array(lineItemSchema).min(1, 'Add at least one line item'),
});
// The form type is INFERRED from the schema — never hand-write it twice.
type OrderForm = z.infer<typeof orderSchema>;
function OrderLineItems() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<OrderForm>({
resolver: zodResolver(orderSchema),
defaultValues: {
customer: '',
items: [{ description: '', quantity: 1, unitPrice: 0 }],
},
});
// `control` links the array to this form instance.
const { fields, append, remove, move } = useFieldArray({
control,
name: 'items',
});
const onSubmit: SubmitHandler<OrderForm> = (data) => {
console.log('Validated order:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="customer">Customer *</label>
<input id="customer" {...register('customer')} />
{errors.customer && (
<p role="alert" className="error">{errors.customer.message}</p>
)}
<ul className="line-items">
{fields.map((field, index) => (
// field.id — NOT index — is the stable React key. This matters!
<li key={field.id} className="line-item">
<input
aria-label={`Item ${index + 1} description`}
{...register(`items.${index}.description`)}
/>
<input
type="number"
aria-label={`Item ${index + 1} quantity`}
{...register(`items.${index}.quantity`)}
/>
<input
type="number"
step="0.01"
aria-label={`Item ${index + 1} unit price`}
{...register(`items.${index}.unitPrice`)}
/>
<button
type="button"
onClick={() => remove(index)}
disabled={fields.length === 1}
aria-label={`Remove item ${index + 1}`}
>
Remove
</button>
</li>
))}
</ul>
{/* Array-level error (e.g. "Add at least one line item") lives on .root */}
{errors.items?.root && (
<p role="alert" className="error">{errors.items.root.message}</p>
)}
<button
type="button"
onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })}
>
+ Add item
</button>
<button type="submit">Place order</button>
</form>
);
}
⚠️ The Number One useFieldArray Bug: Using the Index as a Key
It's tempting to write key={index}. Don't. When the user removes item #2, every item below it shifts up, React reuses DOM nodes by position, and you get inputs showing the wrong row's data — a genuinely baffling bug. React Hook Form generates a stable field.id for exactly this reason. Always use key={field.id}.
💡 Typed Paths and the Full Helper Set
- Typed registration:
register(`items.${index}.quantity`)is fully type-checked in TS 5.x — misspellquantityand the compiler stops you. - Reordering: use
move(from, to)orswap(a, b)for drag-to-reorder — they keep field IDs intact so inputs don't lose focus or state. - Precise inserts:
insert(index, value)andprepend(value)add at a position;replace(newArray)swaps the whole list (handy when loading a saved draft). - Don't mutate
fieldsdirectly — it's a render snapshot. Always go through the helpers so RHF stays in sync.
Live Totals from a Field Array
Field arrays pair naturally with the calculated-field pattern from the previous section. Use useWatch to subscribe to just the items array and derive a total, without re-rendering the whole form on every keystroke:
import { useWatch, type Control } from 'react-hook-form';
function OrderTotal({ control }: { control: Control<OrderForm> }) {
// Subscribe to only the items array — scoped, efficient re-renders.
const items = useWatch({ control, name: 'items' });
const total = (items ?? []).reduce(
(sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0),
0,
);
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(total);
return (
<p className="order-total" aria-live="polite">
Order total: <strong>{formatted}</strong>
</p>
);
}
✅ When to Reach for useFieldArray
Any time the count of a field group is up to the user: invoice rows, team members, tags, questions in a quiz builder, ingredients in a recipe. If you find yourself writing useState<Item[]> plus a pile of add/remove handlers alongside a form library, stop — useFieldArray already does it, typed and validated.
🔎 Async Validation (Debounced Server Checks)
Some questions can only be answered by the server. "Is this username taken?" "Does this promo code exist?" "Is this email already registered?" You can't validate those on the client alone — you have to ask the backend. That's asynchronous validation, and doing it well is mostly about timing: you don't want to fire a network request on every single keystroke.
Picture a user typing "raymond." Without care, that's seven keystrokes and seven requests — and because responses can arrive out of order, an early "raymo" reply might land after the final "raymond" reply and overwrite it with a stale answer. The fix is a two-part combo you already met in the auto-save section: debounce the check so it only runs after the user pauses, and abort any in-flight request when a newer keystroke supersedes it.
React Hook Form Awaits Async Validators for You
Here's the good news: RHF's validate option accepts an async function and simply awaits it. The form's isValidating flag flips to true while the promise is pending, which is exactly what you need to show a "Checking…" hint. We wrap the check in a small debounce helper and cancel superseded requests with an AbortController.
import { useRef } from 'react';
import { useForm } from 'react-hook-form';
// A tiny debounce that resolves with the LATEST call's result.
function debounceAsync<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
delay: number,
) {
let timer: ReturnType<typeof setTimeout> | undefined;
return (...args: A): Promise<R> =>
new Promise((resolve, reject) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn(...args).then(resolve).catch(reject);
}, delay);
});
}
async function checkUsername(
username: string,
signal: AbortSignal,
): Promise<boolean> {
const res = await fetch(
`/api/username-available?u=${encodeURIComponent(username)}`,
{ signal },
);
if (!res.ok) throw new Error('Lookup failed');
const data: { available: boolean } = await res.json();
return data.available;
}
type SignupValues = { username: string };
function UsernameField() {
// Keep a controller across renders so we can abort the previous request.
const abortRef = useRef<AbortController | null>(null);
const {
register,
handleSubmit,
formState: { errors, isValidating },
} = useForm<SignupValues>({ mode: 'onChange' });
const debouncedCheck = useRef(
debounceAsync(async (value: string) => {
abortRef.current?.abort(); // cancel the previous in-flight check
abortRef.current = new AbortController();
try {
const available = await checkUsername(value, abortRef.current.signal);
return available ? true : 'That username is taken';
} catch (err) {
// A cancelled request isn't a real failure — let it pass silently.
if (err instanceof DOMException && err.name === 'AbortError') return true;
return 'Could not check availability — try again';
}
}, 400),
).current;
return (
<form onSubmit={handleSubmit((d) => console.log(d))}>
<label htmlFor="username">Username *</label>
<input
id="username"
aria-invalid={errors.username ? 'true' : 'false'}
aria-describedby="username-status"
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'At least 3 characters' },
validate: (value) => debouncedCheck(value),
})}
/>
<span id="username-status" role="status" aria-live="polite">
{isValidating
? 'Checking availability…'
: errors.username?.message ?? ''}
</span>
</form>
);
}
✅ Why the role="status" / aria-live="polite" Matters
A sighted user sees "Checking availability…" appear and then the result. A screen-reader user gets nothing — unless you put the message in an ARIA live region. aria-live="polite" tells assistive tech to announce the updated text once the user stops typing, so blind users learn the username is taken at the same moment everyone else does. Async validation without a live region is an accessibility bug.
💡 Debouncing Inside Zod
If your schema lives in Zod, you can push the async check into .refine() with an async function and use zodResolver as usual — Zod supports parseAsync and RHF calls it. The debounce/abort concern doesn't disappear, though: wrap the network call the same way. A common production pattern is to keep synchronous rules (length, allowed characters) in Zod and run the server check as a separate debounced validate on just that one field, so cheap rules fail fast and you only hit the network when the input is otherwise valid.
⚠️ Don't Trust the Client Check Alone
A debounced availability check is a UX nicety, not a guarantee. Between the moment the client says "available" and the moment the form submits, someone else could claim the name. Your server must re-check on submit and return a real error (a 409 Conflict, say) that the form surfaces. Client-side async validation makes the happy path pleasant; the server is still the source of truth.
⚛️ React 19 Form Actions & useActionState
Everything so far has used React Hook Form, and for rich, interactive forms it's an excellent choice. But React 19 shipped a set of built-in primitives for forms that are worth knowing — especially for submission-heavy forms, server-driven flows, and cases where you want pending UI and error handling with almost no boilerplate. Let's meet them and, just as importantly, learn when to use which tool.
Forms Can Take a Function as Their action
In React 19, a <form> accepts a function for its action prop. React calls it with the form's FormData when submitted, resets the form on success, and manages the pending state for you — no onSubmit, no preventDefault, no manual useState for "is submitting."
📖 The Three New Primitives
<form action={fn}>: pass a function; React invokes it withFormDataon submit.useActionState(action, initial): wraps an action so its return value becomes state — perfect for validation errors and success messages. Returns[state, formAction, isPending].useFormStatus(): read the parent form's pending state from inside a child component (like a submit button) without prop-drilling.
A Complete Example
Here's a signup form built entirely with React 19 primitives — no form library at all. The action returns a typed state object; useActionState hands it back to us for rendering, and a child SubmitButton reads pending on its own.
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
interface SignupState {
ok: boolean;
error?: string;
}
// The action receives the previous state and the submitted FormData.
async function signupAction(
_prev: SignupState,
formData: FormData,
): Promise<SignupState> {
const email = String(formData.get('email') ?? '');
if (!email.includes('@')) {
return { ok: false, error: 'Please enter a valid email address.' };
}
const res = await fetch('/api/signup', { method: 'POST', body: formData });
if (!res.ok) {
return { ok: false, error: 'Signup failed. Please try again.' };
}
return { ok: true };
}
// useFormStatus() reads the ENCLOSING form's state — must be a child of the form.
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating account…' : 'Create account'}
</button>
);
}
function SignupForm() {
const [state, formAction] = useActionState(signupAction, { ok: false });
return (
<form action={formAction}>
<label htmlFor="email">Email *</label>
<input id="email" name="email" type="email" required />
{state.error && (
<p role="alert" className="error">{state.error}</p>
)}
{state.ok && (
<p role="status" aria-live="polite">Account created! 🎉</p>
)}
<SubmitButton />
</form>
);
}
⚠️ It's useActionState, Not useFormState
During the React 19 betas this hook lived in react-dom as useFormState. In the stable release it was renamed and moved to react as useActionState, and it gained the third return value, isPending. Older tutorials and Stack Overflow answers still show the old name — if you see import { useFormState } from 'react-dom', it's out of date. useFormStatus (no "action") is the separate hook that still lives in react-dom.
React 19 Actions vs. React Hook Form: Which Do I Use?
These aren't rivals so much as tools for different jobs — and they compose. Reach for the one that matches your form's centre of gravity:
| Situation | Lean toward… | Why |
|---|---|---|
| Simple submit-and-show-result (login, newsletter, contact) | React 19 Actions | Built in, minimal code, free pending state and progressive enhancement |
| Rich client UX: per-field errors as you type, wizards, field arrays | React Hook Form | Fine-grained subscriptions, useFieldArray, mature validation ergonomics |
| Server Components / server actions (Next.js App Router) | React 19 Actions | Actions run on the server; the form works even before JS hydrates |
| Complex schema validation with Zod, shared across app | Either (they compose) | Validate FormData with Zod inside an action, or via zodResolver in RHF |
💡 A Note on React.FC and Modern Typing
You'll notice the examples in these newer sections declare components as plain functions — function SignupForm() {…} — and type props inline, rather than using React.FC<Props>. That's the current community-preferred style in React 19 + TS 5.x: React.FC adds little value now that implicit children is gone, and plain functions play more nicely with generics. Both compile fine — you'll see React.FC throughout older code in this lesson and across the ecosystem — but for new components, a typed function declaration is the modern default.
✅ Putting It All Together
You now have the full advanced-forms toolkit: multi-step navigation with typed step state, conditional fields and conditional validation, cascading dependencies and calculated values, dynamic field arrays, debounced async server checks, auto-save, wizards, and React 19's built-in Actions. The through-line across every pattern is the same: let types describe your form's shape, let the library manage the churn, and never let the interface get ahead of what a keyboard-and-screen-reader user can follow. Build that instinct and even a tax-filing-grade form becomes tractable.