๐จ 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 proper state management and navigation
- Implement conditional fields that show/hide based on user input
- Create form wizards with progress tracking and validation per step
- Implement auto-save functionality with debouncing
- Handle field dependencies and computed values
- Make forms accessible with proper ARIA attributes and keyboard navigation
- Persist form data to localStorage for better UX
- Implement review/summary steps before submission
- Handle complex form navigation patterns (back, next, skip, cancel)
Estimated Time: 75-90 minutes
Project: Build a complete multi-step checkout form with conditional fields and auto-save
๐ 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 basic single-page forms with simple validation. These include multi-step navigation, conditional field visibility, field dependencies, auto-save functionality, complex validation rules, and sophisticated user guidance patterns like wizards. They're "advanced" because they require careful state management, user experience consideration, and often integration with multiple systems.
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;
}