
Form Validation Architect
- 138 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
Architect client and server validation for signup, checkout, and settings forms with shared rules, error UX, and security-conscious input handling.
About
Architects robust form validation across frontend and backend: schema design, synchronous and async rules, accessible error messaging, and security for user input. Applies to SaaS onboarding, ecommerce checkout, and mobile forms where bad validation causes abandonment, support load, or data integrity issues.
- Shared schema between client and server
- Field-level and form-level error UX
- Async and cross-field validation patterns
- Security rules for untrusted input
- Accessibility for error announcements
Form Validation Architect by the numbers
- 138 all-time installs (skills.sh)
- Ranked #970 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill form-validation-architectAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 138 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
What it does
Architect client and server validation for signup, checkout, and settings forms with shared rules, error UX, and security-conscious input handling.
Files
Form Validation Architect
Expert in building production-grade form systems with client-side validation, type safety, and excellent UX.
When to Use
✅ Use for:
- Complex forms with multiple fields and validation rules
- Multi-step wizards with progress tracking
- Dynamic field arrays (add/remove items)
- Form state persistence across sessions
- Async validation (check username availability, validate address)
- Dependent fields (enable B when A is checked)
- File uploads with progress and validation
- Autosave and optimistic updates
❌ NOT for:
- Simple contact forms (HTML + basic JS is fine)
- Backend-only validation (use Joi, Yup on server)
- Non-React frameworks (use Formik alternatives)
- Read-only displays (no form needed)
Quick Decision Tree
Does your form:
├── Have >5 fields? → Use react-hook-form
├── Need type safety? → Add Zod schemas
├── Have dynamic fields? → Use field arrays
├── Span multiple steps? → Use wizard pattern
├── Need async validation? → Use resolver + async rules
└── Just email/message? → Use native HTML validation---
Technology Selection (2024+)
React Hook Form (Recommended)
Why RHF over Formik:
- Performance: Uncontrolled inputs → fewer re-renders
- Bundle size: 8KB vs 30KB (Formik)
- DevEx: Better TypeScript support
- Adoption: 40k+ stars, industry standard 2023+
Timeline:
- 2015-2019: Formik dominated
- 2019: React Hook Form released
- 2022+: RHF became standard
- 2024: Formik in maintenance mode
Zod for Schema Validation
Why Zod over Yup:
- TypeScript-first: Infer types from schemas
- Composability: Better schema reuse
- Error messages: More customizable
- Modern: Active development, latest features
Timeline:
- 2017-2020: Yup standard
- 2020: Zod released
- 2023+: Zod preferred for new projects
---
Common Anti-Patterns
Anti-Pattern 1: Controlled Inputs Everywhere
Novice thinking: "All form inputs should be controlled with useState"
Problem: Causes re-render on every keystroke
Wrong approach:
// ❌ Re-renders entire component on every keystroke
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
// ... 20 more useState calls
<input value={email} onChange={(e) => setEmail(e.target.value)} />Correct approach:
// ✅ Uncontrolled with react-hook-form (minimal re-renders)
const { register, handleSubmit } = useForm();
<input {...register('email')} />
<input {...register('password')} />
<input {...register('name')} />Why it matters: Forms with 10+ fields become sluggish with controlled inputs.
---
Anti-Pattern 2: String-Based Validation
Problem: No type safety, easy to make mistakes
Wrong approach:
// ❌ String validation, no types
const validate = (values) => {
if (!values.email.includes('@')) return 'Invalid email';
if (values.age < 18) return 'Must be 18+';
// Typo in field name? Runtime error!
};Correct approach:
// ✅ Zod schema with type inference
const schema = z.object({
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18+'),
username: z.string()
.min(3, 'Too short')
.regex(/^[a-z0-9_]+$/, 'Lowercase, numbers, underscores only')
});
type FormData = z.infer<typeof schema>; // Automatic TypeScript type!Timeline:
- Pre-2020: String-based validation common
- 2020+: Schema-first validation standard
- 2024: Type inference from schemas expected
---
Anti-Pattern 3: No Error State Management
Problem: Errors shown before user interacts
Wrong approach:
// ❌ Shows errors immediately on page load
{errors.email && <span>{errors.email}</span>}Correct approach:
// ✅ Show errors only after field is touched
const { formState: { errors, touchedFields } } = useForm();
{touchedFields.email && errors.email && (
<span className="error">{errors.email.message}</span>
)}
// Or: Use mode="onBlur" to validate on blur
const form = useForm({
mode: 'onBlur' // Validate when user leaves field
});Why it matters: Better UX → user isn't yelled at before typing
---
Anti-Pattern 4: No Async Validation
Problem: Can't check username availability, validate addresses, etc.
Correct approach:
// ✅ Async validation with debounce
const schema = z.object({
username: z.string().refine(
async (username) => {
// Debounced API call
const available = await checkUsernameAvailability(username);
return available;
},
{ message: 'Username already taken' }
)
});
// Or: Custom async validation in RHF
register('username', {
validate: {
checkAvailable: async (value) => {
const response = await fetch(`/api/check-username?q=${value}`);
return response.ok || 'Username taken';
}
}
});Best practice: Debounce async validation to avoid API spam
---
Anti-Pattern 5: No Loading States
Problem: User doesn't know validation is happening
Correct approach:
// ✅ Show loading state during async validation
const { formState: { isValidating, isSubmitting } } = useForm();
<button disabled={isValidating || isSubmitting}>
{isSubmitting ? 'Submitting...' :
isValidating ? 'Checking...' :
'Submit'}
</button>---
Implementation Patterns
Pattern 1: Basic Form with Zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Define schema
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
rememberMe: z.boolean().optional()
});
type LoginForm = z.infer<typeof loginSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: {
rememberMe: false
}
});
const onSubmit = async (data: LoginForm) => {
await api.login(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
{...register('email')}
type="email"
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<input
{...register('password')}
type="password"
placeholder="Password"
/>
{errors.password && <span className="error">{errors.password.message}</span>}
</div>
<div>
<label>
<input {...register('rememberMe')} type="checkbox" />
Remember me
</label>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
);
}Pattern 2: Multi-Step Wizard
const stepSchemas = [
// Step 1: Personal Info
z.object({
firstName: z.string().min(1, 'Required'),
lastName: z.string().min(1, 'Required'),
email: z.string().email()
}),
// Step 2: Address
z.object({
street: z.string().min(1, 'Required'),
city: z.string().min(1, 'Required'),
zipCode: z.string().regex(/^\d{5}$/, 'Invalid ZIP')
}),
// Step 3: Payment
z.object({
cardNumber: z.string().regex(/^\d{16}$/, 'Invalid card'),
expiry: z.string().regex(/^\d{2}\/\d{2}$/, 'MM/YY format'),
cvv: z.string().regex(/^\d{3}$/, '3 digits')
})
];
function MultiStepForm() {
const [step, setStep] = useState(0);
const [formData, setFormData] = useState({});
const form = useForm({
resolver: zodResolver(stepSchemas[step])
});
const nextStep = async () => {
const isValid = await form.trigger(); // Validate current step
if (isValid) {
setFormData({ ...formData, ...form.getValues() });
setStep(step + 1);
}
};
const prevStep = () => {
setFormData({ ...formData, ...form.getValues() });
setStep(step - 1);
};
const onSubmit = async (data) => {
const finalData = { ...formData, ...data };
await api.submitApplication(finalData);
};
return (
<div>
<progress value={step + 1} max={stepSchemas.length} />
<form onSubmit={form.handleSubmit(step === 2 ? onSubmit : nextStep)}>
{step === 0 && <PersonalInfoStep register={form.register} errors={form.formState.errors} />}
{step === 1 && <AddressStep register={form.register} errors={form.formState.errors} />}
{step === 2 && <PaymentStep register={form.register} errors={form.formState.errors} />}
<div>
{step > 0 && <button type="button" onClick={prevStep}>Back</button>}
<button type="submit">
{step === 2 ? 'Submit' : 'Next'}
</button>
</div>
</form>
</div>
);
}Pattern 3: Dynamic Field Arrays
const schema = z.object({
items: z.array(z.object({
name: z.string().min(1, 'Required'),
quantity: z.number().min(1, 'At least 1'),
price: z.number().min(0, 'Must be positive')
})).min(1, 'Add at least one item')
});
function OrderForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: {
items: [{ name: '', quantity: 1, price: 0 }]
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items'
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`items.${index}.name`)}
placeholder="Item name"
/>
<input
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
type="number"
/>
<input
{...register(`items.${index}.price`, { valueAsNumber: true })}
type="number"
step="0.01"
/>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', quantity: 1, price: 0 })}>
Add Item
</button>
<button type="submit">Submit Order</button>
</form>
);
}Pattern 4: Autosave (Debounced)
import { useDebounce } from 'use-debounce';
import { useEffect } from 'react';
function AutosaveForm() {
const { watch, register } = useForm();
const formValues = watch(); // Watch all fields
// Debounce to avoid saving on every keystroke
const [debouncedValues] = useDebounce(formValues, 1000);
useEffect(() => {
// Save to localStorage or API
localStorage.setItem('draft', JSON.stringify(debouncedValues));
// Or: await api.saveDraft(debouncedValues);
}, [debouncedValues]);
return (
<form>
<input {...register('title')} placeholder="Title" />
<textarea {...register('content')} placeholder="Content" />
<small>Autosaved</small>
</form>
);
}---
Form UX Best Practices
1. Validate on Blur (Not on Change)
const form = useForm({
mode: 'onBlur' // Validate when user leaves field
// NOT 'onChange' - too aggressive
});2. Disable Submit While Invalid
<button
type="submit"
disabled={!form.formState.isValid || form.formState.isSubmitting}
>
Submit
</button>3. Focus First Error on Submit
const onSubmit = async (data) => {
try {
await api.submit(data);
} catch (error) {
// Focus first error field
const firstError = Object.keys(errors)[0];
form.setFocus(firstError);
}
};4. Optimistic UI Updates
const onSubmit = async (data) => {
// Optimistically update UI
setItems([...items, data]);
try {
await api.createItem(data);
} catch (error) {
// Rollback on error
setItems(items);
toast.error('Failed to save');
}
};---
Production Checklist
□ Zod schemas for all forms
□ Type inference used (z.infer<typeof schema>)
□ Validation mode set appropriately (onBlur/onSubmit)
□ Error messages clear and actionable
□ Loading states for async operations
□ Focus management on errors
□ Autosave for long forms
□ Form state persisted (localStorage/session)
□ File upload progress indicators
□ Keyboard navigation tested
□ Accessibility (ARIA labels, error announcements)
□ Mobile-friendly (large touch targets)---
When to Use vs Avoid
| Scenario | Use This Skill? |
|---|---|
| User registration with validation | ✅ Yes |
| Multi-step checkout flow | ✅ Yes |
| Dynamic form builder | ✅ Yes |
| Simple newsletter signup | ❌ No - use native HTML |
| Backend-only validation | ❌ No - use Joi/Yup on server |
| Non-React framework | ❌ No - use framework-specific solution |
---
Technology Comparison
| Feature | RHF + Zod | Formik + Yup | Native HTML5 |
|---|---|---|---|
| Performance | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Type Safety | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ❌ |
| Bundle Size | 8KB | 30KB | 0KB |
| DevEx | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| Field Arrays | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ❌ |
| Async Validation | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ❌ |
---
References
/references/zod-patterns.md- Advanced Zod schema patterns/references/accessibility.md- Form accessibility guidelines/references/file-upload.md- File upload with progress tracking
Scripts
scripts/generate_form.ts- Generate form from Zod schemascripts/validate_schemas.ts- Lint Zod schemas for common issues
Assets
assets/form-templates/- Ready-to-use form components
---
This skill guides: Form validation architecture | react-hook-form patterns | Zod schema design | Multi-step wizards | Field arrays | Autosave | Async validation
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
/**
* Login Form Template
*
* Features:
* - Email + password validation
* - Remember me checkbox
* - Loading state
* - Error handling
*
* Usage:
* import { LoginForm } from './LoginForm';
* <LoginForm onSubmit={handleLogin} />
*/
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
rememberMe: z.boolean().optional()
});
type LoginFormData = z.infer<typeof loginSchema>;
interface LoginFormProps {
onSubmit: (data: LoginFormData) => Promise<void>;
onForgotPassword?: () => void;
}
export function LoginForm({ onSubmit, onForgotPassword }: LoginFormProps) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
rememberMe: false
}
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="login-form">
<div className="form-field">
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
placeholder="you@example.com"
{...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-field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
placeholder="Enter 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-field checkbox-field">
<label>
<input type="checkbox" {...register('rememberMe')} />
Remember me
</label>
{onForgotPassword && (
<button
type="button"
onClick={onForgotPassword}
className="link-button"
>
Forgot password?
</button>
)}
</div>
<button type="submit" disabled={isSubmitting} className="submit-button">
{isSubmitting ? 'Logging in...' : 'Log In'}
</button>
</form>
);
}
/**
* Example Styles (CSS Module or styled-components)
*/
const styles = `
.login-form {
max-width: 400px;
margin: 0 auto;
}
.form-field {
margin-bottom: 1rem;
}
.form-field label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-field input[type="email"],
.form-field input[type="password"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-field input[aria-invalid="true"] {
border-color: #d32f2f;
}
.error {
display: block;
margin-top: 0.25rem;
color: #d32f2f;
font-size: 0.875rem;
}
.checkbox-field {
display: flex;
justify-content: space-between;
align-items: center;
}
.checkbox-field label {
margin-bottom: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.link-button {
background: none;
border: none;
color: #2196F3;
text-decoration: underline;
cursor: pointer;
font-size: 0.875rem;
}
.submit-button {
width: 100%;
padding: 0.75rem;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
}
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
`;
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
/**
* Multi-Step Wizard Template
*
* Features:
* - 3-step onboarding flow
* - Progress indicator
* - Per-step validation
* - Back/Next navigation
* - Form state persistence across steps
*
* Usage:
* import { MultiStepWizard } from './MultiStepWizard';
* <MultiStepWizard onComplete={handleComplete} />
*/
// Step schemas
const personalInfoSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Invalid email address')
});
const addressSchema = z.object({
street: z.string().min(1, 'Street address is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(2, 'State is required').max(2, 'Use 2-letter state code'),
zipCode: z.string().regex(/^\d{5}$/, 'ZIP code must be 5 digits')
});
const preferencesSchema = z.object({
notifications: z.boolean(),
newsletter: z.boolean(),
theme: z.enum(['light', 'dark', 'auto'])
});
const stepSchemas = [personalInfoSchema, addressSchema, preferencesSchema];
type PersonalInfo = z.infer<typeof personalInfoSchema>;
type Address = z.infer<typeof addressSchema>;
type Preferences = z.infer<typeof preferencesSchema>;
type CompleteFormData = PersonalInfo & Address & Preferences;
interface MultiStepWizardProps {
onComplete: (data: CompleteFormData) => Promise<void>;
}
export function MultiStepWizard({ onComplete }: MultiStepWizardProps) {
const [step, setStep] = useState(0);
const [formData, setFormData] = useState<Partial<CompleteFormData>>({});
const form = useForm({
resolver: zodResolver(stepSchemas[step]),
defaultValues: formData
});
const stepTitles = [
'Personal Information',
'Address',
'Preferences'
];
const nextStep = async () => {
const isValid = await form.trigger();
if (isValid) {
setFormData({ ...formData, ...form.getValues() });
if (step < stepSchemas.length - 1) {
setStep(step + 1);
form.reset(formData); // Load saved data for next step
}
}
};
const prevStep = () => {
setFormData({ ...formData, ...form.getValues() });
setStep(step - 1);
form.reset(formData);
};
const handleSubmit = async (data: any) => {
const finalData = { ...formData, ...data } as CompleteFormData;
await onComplete(finalData);
};
return (
<div className="wizard-container">
{/* Progress indicator */}
<div className="wizard-progress" role="group" aria-labelledby="wizard-title">
<h2 id="wizard-title" className="sr-only">Account Setup</h2>
<nav aria-label="Form progress">
<ol className="progress-steps">
{stepTitles.map((title, index) => (
<li
key={title}
className={index <= step ? 'active' : ''}
aria-current={index === step ? 'step' : undefined}
>
<span className="step-number">{index + 1}</span>
<span className="step-title">{title}</span>
</li>
))}
</ol>
</nav>
</div>
{/* Announce step changes to screen readers */}
<div role="status" aria-live="polite" className="sr-only">
Step {step + 1} of {stepSchemas.length}: {stepTitles[step]}
</div>
{/* Form */}
<form
onSubmit={form.handleSubmit(step === stepSchemas.length - 1 ? handleSubmit : nextStep)}
className="wizard-form"
>
<div role="region" aria-labelledby={`step-${step}-title`}>
<h3 id={`step-${step}-title`}>{stepTitles[step]}</h3>
{/* Step 1: Personal Info */}
{step === 0 && (
<>
<div className="form-field">
<label htmlFor="firstName">First Name</label>
<input id="firstName" {...form.register('firstName')} />
{form.formState.errors.firstName && (
<span className="error">{form.formState.errors.firstName.message}</span>
)}
</div>
<div className="form-field">
<label htmlFor="lastName">Last Name</label>
<input id="lastName" {...form.register('lastName')} />
{form.formState.errors.lastName && (
<span className="error">{form.formState.errors.lastName.message}</span>
)}
</div>
<div className="form-field">
<label htmlFor="email">Email</label>
<input id="email" type="email" {...form.register('email')} />
{form.formState.errors.email && (
<span className="error">{form.formState.errors.email.message}</span>
)}
</div>
</>
)}
{/* Step 2: Address */}
{step === 1 && (
<>
<div className="form-field">
<label htmlFor="street">Street Address</label>
<input id="street" {...form.register('street')} />
{form.formState.errors.street && (
<span className="error">{form.formState.errors.street.message}</span>
)}
</div>
<div className="form-row">
<div className="form-field">
<label htmlFor="city">City</label>
<input id="city" {...form.register('city')} />
{form.formState.errors.city && (
<span className="error">{form.formState.errors.city.message}</span>
)}
</div>
<div className="form-field">
<label htmlFor="state">State</label>
<input id="state" maxLength={2} {...form.register('state')} />
{form.formState.errors.state && (
<span className="error">{form.formState.errors.state.message}</span>
)}
</div>
<div className="form-field">
<label htmlFor="zipCode">ZIP Code</label>
<input id="zipCode" {...form.register('zipCode')} />
{form.formState.errors.zipCode && (
<span className="error">{form.formState.errors.zipCode.message}</span>
)}
</div>
</div>
</>
)}
{/* Step 3: Preferences */}
{step === 2 && (
<>
<div className="form-field">
<label>
<input type="checkbox" {...form.register('notifications')} />
Enable email notifications
</label>
</div>
<div className="form-field">
<label>
<input type="checkbox" {...form.register('newsletter')} />
Subscribe to newsletter
</label>
</div>
<div className="form-field">
<label htmlFor="theme">Theme</label>
<select id="theme" {...form.register('theme')}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto</option>
</select>
</div>
</>
)}
</div>
{/* Navigation */}
<div className="wizard-actions">
{step > 0 && (
<button type="button" onClick={prevStep} className="btn-secondary">
Back
</button>
)}
<button
type="submit"
disabled={form.formState.isSubmitting}
className="btn-primary"
>
{step === stepSchemas.length - 1
? form.formState.isSubmitting
? 'Submitting...'
: 'Complete'
: 'Next'}
</button>
</div>
</form>
</div>
);
}
/**
* Example Styles (CSS Module or styled-components)
*/
const styles = `
.wizard-container {
max-width: 600px;
margin: 0 auto;
}
.wizard-progress {
margin-bottom: 2rem;
}
.progress-steps {
display: flex;
justify-content: space-between;
list-style: none;
padding: 0;
margin: 0;
}
.progress-steps li {
flex: 1;
text-align: center;
position: relative;
padding-bottom: 2rem;
}
.progress-steps li:not(:last-child)::after {
content: '';
position: absolute;
top: 1rem;
left: 50%;
width: 100%;
height: 2px;
background: #ddd;
}
.progress-steps li.active:not(:last-child)::after {
background: #2196F3;
}
.step-number {
display: inline-block;
width: 2rem;
height: 2rem;
line-height: 2rem;
border-radius: 50%;
background: #ddd;
color: #666;
font-weight: bold;
}
.progress-steps li.active .step-number {
background: #2196F3;
color: white;
}
.step-title {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
color: #666;
}
.progress-steps li.active .step-title {
color: #2196F3;
font-weight: 500;
}
.form-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 1rem;
}
.wizard-actions {
display: flex;
justify-content: space-between;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #ddd;
}
.btn-secondary {
padding: 0.75rem 1.5rem;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
padding: 0.75rem 2rem;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
`;
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
/**
* Registration Form Template
*
* Features:
* - Email, password, confirm password validation
* - Password strength indicator
* - Terms of service checkbox
* - Cross-field validation (passwords match)
*
* Usage:
* import { RegistrationForm } from './RegistrationForm';
* <RegistrationForm onSubmit={handleRegister} />
*/
const registrationSchema = 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 an uppercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
confirmPassword: z.string(),
acceptTerms: z.boolean().refine((val) => val === true, {
message: 'You must accept the terms and conditions'
})
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
type RegistrationFormData = z.infer<typeof registrationSchema>;
interface RegistrationFormProps {
onSubmit: (data: Omit<RegistrationFormData, 'confirmPassword'>) => Promise<void>;
onTermsClick?: () => void;
}
export function RegistrationForm({ onSubmit, onTermsClick }: RegistrationFormProps) {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting }
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
mode: 'onBlur'
});
const password = watch('password', '');
// Calculate password strength
const getPasswordStrength = (pwd: string): { score: number; label: string } => {
if (!pwd) return { score: 0, label: '' };
let score = 0;
if (pwd.length >= 8) score++;
if (pwd.length >= 12) score++;
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++;
if (/[0-9]/.test(pwd)) score++;
if (/[^A-Za-z0-9]/.test(pwd)) score++;
const labels = ['Weak', 'Fair', 'Good', 'Strong', 'Very Strong'];
return { score, label: labels[Math.min(score - 1, 4)] };
};
const strength = getPasswordStrength(password);
const handleFormSubmit = async (data: RegistrationFormData) => {
const { confirmPassword, ...submitData } = data;
await onSubmit(submitData);
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="registration-form">
<div className="form-field">
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
placeholder="you@example.com"
{...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-field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
placeholder="Enter password"
{...register('password')}
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby="password-requirements"
/>
<span id="password-requirements" className="help-text">
Must be at least 8 characters with one uppercase letter and one number
</span>
{password && (
<div className="password-strength">
<div
className={`strength-bar strength-${strength.score}`}
style={{ width: `${(strength.score / 5) * 100}%` }}
/>
<span className="strength-label">{strength.label}</span>
</div>
)}
{errors.password && (
<span className="error" role="alert">
{errors.password.message}
</span>
)}
</div>
<div className="form-field">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
placeholder="Re-enter password"
{...register('confirmPassword')}
aria-invalid={errors.confirmPassword ? 'true' : 'false'}
/>
{errors.confirmPassword && (
<span className="error" role="alert">
{errors.confirmPassword.message}
</span>
)}
</div>
<div className="form-field">
<label className="checkbox-label">
<input type="checkbox" {...register('acceptTerms')} />
I accept the{' '}
{onTermsClick ? (
<button type="button" onClick={onTermsClick} className="link-button">
terms and conditions
</button>
) : (
<span>terms and conditions</span>
)}
</label>
{errors.acceptTerms && (
<span className="error" role="alert">
{errors.acceptTerms.message}
</span>
)}
</div>
<button type="submit" disabled={isSubmitting} className="submit-button">
{isSubmitting ? 'Creating account...' : 'Create Account'}
</button>
</form>
);
}
/**
* Example Styles (CSS Module or styled-components)
*/
const styles = `
.password-strength {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.strength-bar {
height: 4px;
border-radius: 2px;
transition: all 0.3s ease;
}
.strength-bar.strength-1 { background: #d32f2f; }
.strength-bar.strength-2 { background: #ff9800; }
.strength-bar.strength-3 { background: #ffc107; }
.strength-bar.strength-4 { background: #8bc34a; }
.strength-bar.strength-5 { background: #4caf50; }
.strength-label {
font-size: 0.75rem;
font-weight: 500;
}
.help-text {
display: block;
margin-top: 0.25rem;
font-size: 0.875rem;
color: #666;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
`;
Form Accessibility Guidelines
Making forms usable for everyone, including screen reader users and keyboard-only navigation.
WCAG 2.1 AA Compliance Checklist
□ All inputs have associated labels
□ Error messages are announced to screen readers
□ Focus order follows logical tab sequence
□ Required fields are indicated (not just color)
□ Color contrast ≥ 4.5:1 for normal text
□ Form can be completed with keyboard alone
□ Error recovery is possible without mouse
□ Time limits are adjustable or disabled
□ Instructions provided before form start
□ Success/failure clearly communicated---
Pattern 1: Proper Label Association
❌ Wrong: Label not associated with input
<label>Email</label>
<input name="email" />✅ Correct: Explicit association
<label htmlFor="email">Email Address</label>
<input id="email" name="email" type="email" />
// Or: Implicit association
<label>
Email Address
<input name="email" type="email" />
</label>Why: Screen readers announce the label when input is focused.
---
Pattern 2: Required Field Indication
❌ Wrong: Only using asterisk
<label>Email *</label>
<input required />✅ Correct: Explicit aria-required + visual indicator
<label htmlFor="email">
Email Address
<span aria-label="required" className="required">*</span>
</label>
<input
id="email"
name="email"
required
aria-required="true"
/>Better: Text indicator
<label htmlFor="email">
Email Address <span className="required">(required)</span>
</label>---
Pattern 3: Error Announcement
❌ Wrong: Error not announced
{errors.email && <span className="error">{errors.email.message}</span>}✅ Correct: aria-describedby + aria-invalid
<input
id="email"
name="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>
)}Why: role="alert" causes screen readers to announce the error immediately.
---
Pattern 4: Live Region for Form Status
Announce submission status without page reload.
function ContactForm() {
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
{/* Live region announces status changes */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{status === 'submitting' && 'Submitting form...'}
{status === 'success' && 'Form submitted successfully'}
{status === 'error' && 'Form submission failed. Please check errors.'}
</div>
{/* Visual status (also shown) */}
{status === 'success' && (
<div className="success-message">Thank you! Form submitted.</div>
)}
</form>
);
}---
Pattern 5: Focus Management
Focus first error on submit failure.
const { setFocus, formState: { errors } } = useForm();
const onSubmit = async (data) => {
try {
await api.submit(data);
} catch (error) {
// Focus first error field
const firstErrorField = Object.keys(errors)[0];
if (firstErrorField) {
setFocus(firstErrorField);
}
}
};Auto-focus first field on page load (only if form is primary content):
useEffect(() => {
const firstInput = document.querySelector('input');
firstInput?.focus();
}, []);---
Pattern 6: Keyboard Navigation
All interactive elements must be keyboard-accessible.
// ✅ Custom checkbox with keyboard support
<label>
<input
type="checkbox"
className="sr-only"
{...register('terms')}
/>
<span
role="checkbox"
aria-checked={watch('terms')}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setValue('terms', !watch('terms'));
}
}}
className="custom-checkbox"
/>
I agree to the terms
</label>Tab order: Ensure tabindex follows logical flow (top-to-bottom, left-to-right).
---
Pattern 7: Fieldset and Legend
Group related fields semantically.
<fieldset>
<legend>Shipping Address</legend>
<label htmlFor="street">Street Address</label>
<input id="street" name="street" />
<label htmlFor="city">City</label>
<input id="city" name="city" />
<label htmlFor="zipCode">ZIP Code</label>
<input id="zipCode" name="zipCode" />
</fieldset>
<fieldset>
<legend>Payment Method</legend>
<label>
<input type="radio" name="paymentMethod" value="card" />
Credit Card
</label>
<label>
<input type="radio" name="paymentMethod" value="paypal" />
PayPal
</label>
</fieldset>Why: Screen readers announce the legend when entering the fieldset.
---
Pattern 8: Instructions and Help Text
Provide context before users start typing.
<label htmlFor="password">
Password
<span id="password-requirements" className="help-text">
Must be at least 8 characters with one uppercase letter and one number
</span>
</label>
<input
id="password"
type="password"
aria-describedby="password-requirements"
/>Progressive disclosure for complex instructions:
<label htmlFor="taxId">Tax ID</label>
<button
type="button"
aria-expanded={showHelp}
aria-controls="taxid-help"
onClick={() => setShowHelp(!showHelp)}
>
What's this?
</button>
{showHelp && (
<div id="taxid-help" role="region">
Your Tax ID is a 9-digit number assigned by the IRS...
</div>
)}
<input id="taxId" aria-describedby="taxid-help" />---
Pattern 9: Color Contrast
WCAG AA requires minimum contrast ratios.
Text Contrast:
- Normal text: 4.5:1
- Large text (18pt+): 3:1
- UI components: 3:1
/* ✅ Good contrast */
.error {
color: #d32f2f; /* Red text */
background: #ffffff; /* White bg */
/* Contrast ratio: 5.0:1 */
}
/* ❌ Poor contrast */
.error {
color: #ff9999; /* Light red */
background: #ffffff;
/* Contrast ratio: 2.1:1 - FAILS */
}Don't rely on color alone:
// ❌ Only color indicates error
<input className={errors.email ? 'input-error' : ''} />
// ✅ Icon + color + text
<input className={errors.email ? 'input-error' : ''} />
{errors.email && (
<span className="error">
<IconError aria-hidden="true" />
{errors.email.message}
</span>
)}---
Pattern 10: Screen Reader Only Content
Hide visual clutter, provide context for assistive tech.
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}Usage:
<button type="submit">
<IconPaperPlane aria-hidden="true" />
<span className="sr-only">Submit form</span>
</button>---
Pattern 11: Time Limits
Allow users to extend or disable timeouts.
function TimedForm() {
const [timeLeft, setTimeLeft] = useState(300); // 5 minutes
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft((t) => Math.max(0, t - 1));
}, 1000);
return () => clearInterval(timer);
}, []);
// Warn at 1 minute remaining
const showWarning = timeLeft <= 60 && timeLeft > 0;
return (
<form>
{showWarning && (
<div role="alert" className="warning">
⏰ {timeLeft} seconds remaining.
<button
type="button"
onClick={() => setTimeLeft(300)}
>
Extend time
</button>
</div>
)}
{/* Form fields */}
</form>
);
}---
Pattern 12: Multi-Step Forms (Wizard)
Indicate progress and allow navigation.
<div role="group" aria-labelledby="wizard-title">
<h2 id="wizard-title">Account Setup</h2>
{/* Progress indicator */}
<nav aria-label="Form progress">
<ol className="wizard-steps">
<li aria-current={step === 1 ? 'step' : undefined}>
Personal Info
</li>
<li aria-current={step === 2 ? 'step' : undefined}>
Address
</li>
<li aria-current={step === 3 ? 'step' : undefined}>
Payment
</li>
</ol>
</nav>
{/* Announce step changes */}
<div role="status" aria-live="polite" className="sr-only">
Step {step} of 3: {stepTitles[step]}
</div>
{/* Step content */}
<div role="region" aria-labelledby={`step-${step}-title`}>
<h3 id={`step-${step}-title`}>{stepTitles[step]}</h3>
{/* Fields */}
</div>
</div>---
Testing Tools
Automated Testing
- axe DevTools: Browser extension for WCAG violations
- pa11y: CLI tool for automated accessibility testing
- WAVE: Web Accessibility Evaluation Tool
# Run pa11y on form page
npx pa11y http://localhost:3000/signupManual Testing
1. Keyboard only: Tab through form, submit with Enter 2. Screen reader: Test with NVDA (Windows), JAWS, or VoiceOver (Mac) 3. Zoom: Test at 200% zoom (WCAG requirement) 4. High contrast: Enable high contrast mode (Windows)
VoiceOver (Mac):
- Enable: Cmd + F5
- Navigate: VO + Right Arrow
- Interact: VO + Space
---
Production Checklist
□ All inputs have visible labels
□ Required fields indicated with text (not just *)
□ Errors announced with role="alert"
□ aria-invalid on fields with errors
□ Focus moves to first error on submit failure
□ Color contrast ≥ 4.5:1
□ Form completable with keyboard only
□ Tab order is logical
□ Fieldsets group related fields
□ Help text associated with aria-describedby
□ Success/error messages use live regions
□ Time limits can be extended
□ Multi-step progress is announced
□ Tested with screen reader
□ No reliance on color alone for meaning---
Resources
File Upload with Progress Tracking
Production patterns for handling file uploads in forms with validation, progress indicators, and error handling.
Pattern 1: Basic File Upload with Validation
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const schema = z.object({
avatar: z
.instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, {
message: 'File must be less than 5MB'
})
.refine((file) => ACCEPTED_TYPES.includes(file.type), {
message: 'Only JPEG, PNG, and WebP images allowed'
})
});
function FileUploadForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema)
});
const onSubmit = async (data: { avatar: File }) => {
const formData = new FormData();
formData.append('avatar', data.avatar);
await fetch('/api/upload', {
method: 'POST',
body: formData
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="avatar">Profile Picture</label>
<input
id="avatar"
type="file"
accept="image/jpeg,image/png,image/webp"
{...register('avatar', {
// Convert FileList to File
setValueAs: (files: FileList) => files[0]
})}
/>
{errors.avatar && (
<span className="error">{errors.avatar.message}</span>
)}
<button type="submit">Upload</button>
</form>
);
}---
Pattern 2: Preview Before Upload
Show image preview before submitting.
function ImageUploadWithPreview() {
const [preview, setPreview] = useState<string | null>(null);
const { register, watch } = useForm();
const file = watch('image')?.[0];
useEffect(() => {
if (!file) {
setPreview(null);
return;
}
const objectUrl = URL.createObjectURL(file);
setPreview(objectUrl);
// Cleanup
return () => URL.revokeObjectURL(objectUrl);
}, [file]);
return (
<div>
<input
type="file"
accept="image/*"
{...register('image')}
/>
{preview && (
<div className="preview">
<img src={preview} alt="Preview" style={{ maxWidth: 300 }} />
<button
type="button"
onClick={() => {
setPreview(null);
setValue('image', null);
}}
>
Remove
</button>
</div>
)}
</div>
);
}---
Pattern 3: Upload Progress with XMLHttpRequest
Track upload progress for large files.
function UploadWithProgress() {
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const uploadFile = async (file: File) => {
setUploading(true);
setProgress(0);
const formData = new FormData();
formData.append('file', file);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// Track upload progress
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
setProgress(percentComplete);
}
});
xhr.addEventListener('load', () => {
setUploading(false);
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error('Upload failed'));
}
});
xhr.addEventListener('error', () => {
setUploading(false);
reject(new Error('Network error'));
});
xhr.open('POST', '/api/upload');
xhr.send(formData);
});
};
return (
<div>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
}}
disabled={uploading}
/>
{uploading && (
<div className="progress-container">
<div
className="progress-bar"
style={{ width: `${progress}%` }}
/>
<span>{Math.round(progress)}%</span>
</div>
)}
</div>
);
}---
Pattern 4: Multiple File Upload
Handle multiple files with individual progress tracking.
interface FileUpload {
id: string;
file: File;
progress: number;
status: 'pending' | 'uploading' | 'complete' | 'error';
error?: string;
}
function MultiFileUpload() {
const [uploads, setUploads] = useState<FileUpload[]>([]);
const handleFileSelect = (files: FileList) => {
const newUploads = Array.from(files).map((file) => ({
id: Math.random().toString(36),
file,
progress: 0,
status: 'pending' as const
}));
setUploads((prev) => [...prev, ...newUploads]);
// Start uploading
newUploads.forEach((upload) => uploadFile(upload));
};
const uploadFile = async (upload: FileUpload) => {
setUploads((prev) =>
prev.map((u) =>
u.id === upload.id ? { ...u, status: 'uploading' } : u
)
);
const formData = new FormData();
formData.append('file', upload.file);
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = (e.loaded / e.total) * 100;
setUploads((prev) =>
prev.map((u) =>
u.id === upload.id ? { ...u, progress } : u
)
);
}
});
await new Promise((resolve, reject) => {
xhr.addEventListener('load', () => {
if (xhr.status === 200) resolve(xhr.response);
else reject(new Error('Upload failed'));
});
xhr.addEventListener('error', reject);
xhr.open('POST', '/api/upload');
xhr.send(formData);
});
setUploads((prev) =>
prev.map((u) =>
u.id === upload.id ? { ...u, status: 'complete', progress: 100 } : u
)
);
} catch (error) {
setUploads((prev) =>
prev.map((u) =>
u.id === upload.id
? { ...u, status: 'error', error: error.message }
: u
)
);
}
};
const removeUpload = (id: string) => {
setUploads((prev) => prev.filter((u) => u.id !== id));
};
return (
<div>
<input
type="file"
multiple
onChange={(e) => {
if (e.target.files) handleFileSelect(e.target.files);
}}
/>
<div className="upload-list">
{uploads.map((upload) => (
<div key={upload.id} className="upload-item">
<span>{upload.file.name}</span>
{upload.status === 'uploading' && (
<div className="progress">
<div style={{ width: `${upload.progress}%` }} />
</div>
)}
{upload.status === 'complete' && (
<span className="success">✓ Uploaded</span>
)}
{upload.status === 'error' && (
<span className="error">{upload.error}</span>
)}
<button onClick={() => removeUpload(upload.id)}>
Remove
</button>
</div>
))}
</div>
</div>
);
}---
Pattern 5: Drag and Drop
Enhance UX with drag-and-drop support.
function DragDropUpload() {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
handleFiles(files);
};
const handleFiles = (files: File[]) => {
files.forEach((file) => {
// Validate and upload
if (file.size > MAX_FILE_SIZE) {
alert(`${file.name} is too large`);
return;
}
uploadFile(file);
});
};
return (
<div
className={`dropzone ${isDragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
multiple
onChange={(e) => {
if (e.target.files) handleFiles(Array.from(e.target.files));
}}
style={{ display: 'none' }}
id="file-input"
/>
<label htmlFor="file-input" className="drop-label">
{isDragging ? (
<>Drop files here</>
) : (
<>
Drag & drop files here, or <span className="link">browse</span>
</>
)}
</label>
</div>
);
}Styles:
.dropzone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.dropzone.dragging {
border-color: #4CAF50;
background: #f0f9f0;
}
.dropzone .link {
color: #2196F3;
text-decoration: underline;
}---
Pattern 6: Chunked Upload (Large Files)
Split large files into chunks for reliable uploads.
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
async function uploadFileInChunks(file: File) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadId = Math.random().toString(36);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('uploadId', uploadId);
formData.append('chunkIndex', chunkIndex.toString());
formData.append('totalChunks', totalChunks.toString());
formData.append('fileName', file.name);
await fetch('/api/upload-chunk', {
method: 'POST',
body: formData
});
// Update progress
const progress = ((chunkIndex + 1) / totalChunks) * 100;
console.log(`Upload progress: ${progress}%`);
}
// Finalize upload (merge chunks on server)
await fetch('/api/finalize-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, fileName: file.name })
});
}Server-side (Node.js example):
// Receive chunk
app.post('/api/upload-chunk', async (req, res) => {
const { uploadId, chunkIndex, totalChunks, fileName } = req.body;
const chunk = req.files.chunk;
const chunkDir = path.join('/tmp/uploads', uploadId);
await fs.mkdir(chunkDir, { recursive: true });
const chunkPath = path.join(chunkDir, `chunk-${chunkIndex}`);
await chunk.mv(chunkPath);
res.json({ success: true });
});
// Merge chunks
app.post('/api/finalize-upload', async (req, res) => {
const { uploadId, fileName } = req.body;
const chunkDir = path.join('/tmp/uploads', uploadId);
const finalPath = path.join('/uploads', fileName);
const writeStream = fs.createWriteStream(finalPath);
const chunkFiles = await fs.readdir(chunkDir);
chunkFiles.sort((a, b) => {
const aIndex = parseInt(a.split('-')[1]);
const bIndex = parseInt(b.split('-')[1]);
return aIndex - bIndex;
});
for (const chunkFile of chunkFiles) {
const chunkPath = path.join(chunkDir, chunkFile);
const data = await fs.readFile(chunkPath);
writeStream.write(data);
}
writeStream.end();
await fs.rm(chunkDir, { recursive: true });
res.json({ success: true, url: `/uploads/${fileName}` });
});---
Pattern 7: Image Compression Before Upload
Reduce file size client-side before uploading.
async function compressImage(file: File, maxWidth = 1920): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
img.onload = () => {
const scaleFactor = Math.min(1, maxWidth / img.width);
canvas.width = img.width * scaleFactor;
canvas.height = img.height * scaleFactor;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error('Compression failed'));
},
'image/jpeg',
0.9 // Quality (0-1)
);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
// Usage
async function handleImageUpload(file: File) {
if (file.size > 2 * 1024 * 1024) {
// Compress if > 2MB
const compressed = await compressImage(file);
uploadFile(compressed);
} else {
uploadFile(file);
}
}---
Production Checklist
□ File size validation (client + server)
□ File type validation (MIME type check)
□ Progress indicator for uploads >1MB
□ Error handling with retry capability
□ Cancel upload functionality
□ Image compression for large files
□ Chunked upload for files >10MB
□ Drag-and-drop support
□ Preview for image uploads
□ Accessible file input labels
□ Security: virus scanning on server
□ Security: filename sanitization
□ Security: storage quota enforcement
□ HTTPS required for uploads---
Security Considerations
1. Validate MIME type on server: Don't trust client-side checks 2. Rename files: Avoid executing uploaded scripts 3. Store outside web root: Prevent direct access 4. Virus scanning: Use ClamAV or similar 5. Rate limiting: Prevent abuse 6. Authentication: Require login for uploads
// Server-side validation (Node.js)
import fileType from 'file-type';
app.post('/upload', async (req, res) => {
const file = req.files.upload;
// Validate actual file type
const type = await fileType.fromBuffer(file.data);
if (!['image/jpeg', 'image/png'].includes(type.mime)) {
return res.status(400).json({ error: 'Invalid file type' });
}
// Generate safe filename
const ext = type.ext;
const safeName = `${Date.now()}-${Math.random().toString(36)}.${ext}`;
// Store outside public directory
await file.mv(`/var/uploads/${safeName}`);
res.json({ url: `/files/${safeName}` });
});---
Resources
- MDN: File API
- MDN: FormData
- react-dropzone - Popular drag-and-drop library
- Uppy - Full-featured upload library
Advanced Zod Schema Patterns
Production patterns for complex validation with Zod.
Pattern 1: Conditional Validation (Dependent Fields)
Validate field B based on field A's value.
const schema = z.object({
accountType: z.enum(['personal', 'business']),
businessName: z.string().optional(),
taxId: z.string().optional()
}).refine(
(data) => {
if (data.accountType === 'business') {
return !!data.businessName && !!data.taxId;
}
return true;
},
{
message: 'Business name and tax ID required for business accounts',
path: ['businessName'] // Error appears on this field
}
);Pattern 2: Cross-Field Validation
Validate relationships between multiple fields.
const dateRangeSchema = z.object({
startDate: z.date(),
endDate: z.date()
}).refine(
(data) => data.endDate > data.startDate,
{
message: 'End date must be after start date',
path: ['endDate']
}
);
// Password confirmation
const passwordSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword']
}
);Pattern 3: Transform and Sanitize
Clean user input before validation.
const phoneSchema = z.string()
.transform((val) => val.replace(/\D/g, '')) // Remove non-digits
.pipe(
z.string()
.length(10, 'Phone must be 10 digits')
.regex(/^[2-9]/, 'Invalid area code')
);
// Email normalization
const emailSchema = z.string()
.email()
.transform((val) => val.toLowerCase().trim());
// Currency parsing
const priceSchema = z.string()
.transform((val) => parseFloat(val.replace(/[$,]/g, '')))
.pipe(
z.number()
.positive('Price must be positive')
.max(1000000, 'Price too high')
);Pattern 4: Union Types with Discriminators
Type-safe unions for polymorphic data.
const paymentSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('card'),
cardNumber: z.string().regex(/^\d{16}$/),
cvv: z.string().regex(/^\d{3}$/)
}),
z.object({
method: z.literal('paypal'),
email: z.string().email()
}),
z.object({
method: z.literal('bank'),
accountNumber: z.string(),
routingNumber: z.string()
})
]);
type Payment = z.infer<typeof paymentSchema>;
// TypeScript knows which fields exist based on 'method'Pattern 5: Recursive Schemas
Self-referential data structures (comments, file trees).
interface Comment {
id: string;
text: string;
replies: Comment[];
}
const commentSchema: z.ZodType<Comment> = z.lazy(() =>
z.object({
id: z.string(),
text: z.string().min(1).max(500),
replies: z.array(commentSchema)
})
);
// File system tree
const fileNodeSchema: z.ZodType<any> = z.lazy(() =>
z.object({
name: z.string(),
type: z.enum(['file', 'folder']),
children: z.array(fileNodeSchema).optional()
})
);Pattern 6: Custom Validators (refine)
Complex business logic validation.
// Check username availability (async)
const usernameSchema = z.string()
.min(3)
.max(20)
.regex(/^[a-z0-9_]+$/, 'Lowercase, numbers, underscores only')
.refine(
async (username) => {
const response = await fetch(`/api/check-username?q=${username}`);
return response.ok;
},
{ message: 'Username already taken' }
);
// Validate file size
const fileSchema = z.instanceof(File)
.refine(
(file) => file.size <= 5 * 1024 * 1024,
{ message: 'File must be less than 5MB' }
)
.refine(
(file) => ['image/jpeg', 'image/png'].includes(file.type),
{ message: 'Only JPEG and PNG allowed' }
);
// Business hours validation
const appointmentSchema = z.object({
date: z.date()
}).refine(
(data) => {
const day = data.date.getDay();
const hour = data.date.getHours();
return day >= 1 && day <= 5 && hour >= 9 && hour < 17;
},
{ message: 'Appointments only available Mon-Fri, 9 AM - 5 PM' }
);Pattern 7: Schema Composition (Reuse)
Build complex schemas from primitives.
// Base schemas
const emailField = z.string().email('Invalid email');
const passwordField = z.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Needs uppercase')
.regex(/[0-9]/, 'Needs number');
// Compose into registration
const registrationSchema = z.object({
email: emailField,
password: passwordField,
confirmPassword: z.string()
}).refine(
(data) => data.password === data.confirmPassword,
{ path: ['confirmPassword'], message: 'Passwords must match' }
);
// Extend for profile update
const profileUpdateSchema = registrationSchema
.omit({ password: true, confirmPassword: true })
.extend({
firstName: z.string().min(1),
lastName: z.string().min(1),
bio: z.string().max(500).optional()
});Pattern 8: Partial and Pick
Create variations of schemas.
const userSchema = z.object({
id: z.string(),
email: z.string().email(),
firstName: z.string(),
lastName: z.string(),
role: z.enum(['admin', 'user'])
});
// For updates: all fields optional
const userUpdateSchema = userSchema.partial();
// For creation: omit auto-generated fields
const userCreateSchema = userSchema.omit({ id: true });
// For public API: only safe fields
const userPublicSchema = userSchema.pick({
id: true,
firstName: true,
lastName: true
});Pattern 9: Default Values and Preprocessing
Set defaults before validation.
const configSchema = z.object({
theme: z.enum(['light', 'dark']).default('light'),
notifications: z.boolean().default(true),
itemsPerPage: z.number().min(10).max(100).default(25),
tags: z.array(z.string()).default([])
});
// Preprocessing: Normalize before validate
const searchSchema = z.object({
query: z.string().trim().min(1),
filters: z.record(z.string()).default({})
}).transform((data) => ({
...data,
query: data.query.toLowerCase()
}));Pattern 10: Error Customization
Provide context-aware error messages.
const schema = z.object({
age: z.number({
required_error: 'Age is required',
invalid_type_error: 'Age must be a number'
})
.min(18, 'Must be at least 18 years old')
.max(120, 'Age seems invalid'),
email: z.string({
required_error: 'Email address is required'
}).email({
message: 'Please enter a valid email address'
})
});
// Custom error map for entire form
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === 'string') {
return { message: 'This field must be text' };
}
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);Pattern 11: Branded Types
Create distinct types for primitives.
// Prevent mixing userId with productId
const UserIdSchema = z.string().uuid().brand('UserId');
type UserId = z.infer<typeof UserIdSchema>;
const ProductIdSchema = z.string().uuid().brand('ProductId');
type ProductId = z.infer<typeof ProductIdSchema>;
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
// TypeScript error: userId is not assignable to ProductId
const userId = UserIdSchema.parse('...');
getProduct(userId); // ❌ Type error!Pattern 12: SuperRefine (Multiple Errors)
Return multiple validation errors at once.
const schema = z.object({
password: z.string(),
confirmPassword: z.string()
}).superRefine((data, ctx) => {
if (data.password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: 8,
type: 'string',
inclusive: true,
path: ['password'],
message: 'Password must be at least 8 characters'
});
}
if (!/[A-Z]/.test(data.password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['password'],
message: 'Password must contain an uppercase letter'
});
}
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirmPassword'],
message: 'Passwords do not match'
});
}
});Production Checklist
□ All user inputs validated with Zod
□ Error messages are user-friendly (not technical)
□ Async validation debounced (500ms+)
□ File uploads have size/type constraints
□ Dates validated for business logic (hours, holidays)
□ Dependent fields use refine() properly
□ Schemas reused via composition (DRY)
□ Custom error maps for i18n
□ Branded types for IDs prevent mixing
□ SuperRefine for complex multi-field validationCommon Pitfalls
1. Async refine without debounce: Spams API on every keystroke 2. Missing path in refine: Error appears on wrong field 3. Transform before validation: Use .pipe() to validate after transform 4. Not using discriminatedUnion: Poor TypeScript inference on unions 5. Overly strict regex: Rejects valid input (international phone numbers, etc.)
#!/usr/bin/env node
/**
* Generate React Hook Form components from Zod schemas
*
* Usage: npx tsx generate_form.ts <schema-file> <output-file>
*
* Example:
* npx tsx generate_form.ts ./schemas/login.ts ./components/LoginForm.tsx
*
* Dependencies: npm install zod react-hook-form @hookform/resolvers
*/
import { z } from 'zod';
import * as fs from 'fs';
import * as path from 'path';
interface FieldConfig {
name: string;
type: 'text' | 'email' | 'password' | 'number' | 'checkbox' | 'textarea' | 'select';
label: string;
placeholder?: string;
options?: string[];
}
function inferFieldType(zodSchema: z.ZodTypeAny): FieldConfig['type'] {
if (zodSchema instanceof z.ZodString) {
const checks = (zodSchema as any)._def.checks || [];
if (checks.some((c: any) => c.kind === 'email')) return 'email';
return 'text';
}
if (zodSchema instanceof z.ZodNumber) return 'number';
if (zodSchema instanceof z.ZodBoolean) return 'checkbox';
if (zodSchema instanceof z.ZodEnum) return 'select';
return 'text';
}
function generateFormComponent(
schemaName: string,
fields: FieldConfig[],
componentName: string
): string {
const imports = `import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { ${schemaName} } from './schemas';
import type { z } from 'zod';
type FormData = z.infer<typeof ${schemaName}>;
`;
const formFields = fields.map(field => {
if (field.type === 'checkbox') {
return ` <div className="form-field">
<label>
<input
{...register('${field.name}')}
type="checkbox"
/>
${field.label}
</label>
{errors.${field.name} && (
<span className="error">{errors.${field.name}.message}</span>
)}
</div>`;
}
if (field.type === 'select' && field.options) {
return ` <div className="form-field">
<label htmlFor="${field.name}">${field.label}</label>
<select {...register('${field.name}')} id="${field.name}">
<option value="">Select...</option>
${field.options.map(opt => `<option value="${opt}">${opt}</option>`).join('\n ')}
</select>
{errors.${field.name} && (
<span className="error">{errors.${field.name}.message}</span>
)}
</div>`;
}
if (field.type === 'textarea') {
return ` <div className="form-field">
<label htmlFor="${field.name}">${field.label}</label>
<textarea
{...register('${field.name}')}
id="${field.name}"
placeholder="${field.placeholder || ''}"
rows={4}
/>
{errors.${field.name} && (
<span className="error">{errors.${field.name}.message}</span>
)}
</div>`;
}
return ` <div className="form-field">
<label htmlFor="${field.name}">${field.label}</label>
<input
{...register('${field.name}'${field.type === 'number' ? ', { valueAsNumber: true }' : ''})}
type="${field.type}"
id="${field.name}"
placeholder="${field.placeholder || ''}"
/>
{errors.${field.name} && (
<span className="error">{errors.${field.name}.message}</span>
)}
</div>`;
}).join('\n\n');
return `${imports}
export function ${componentName}() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(${schemaName})
});
const onSubmit = async (data: FormData) => {
console.log('Form submitted:', data);
// TODO: Add your submission logic here
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="form-container">
${formFields}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
`;
}
// Example usage
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length < 2) {
console.log('Usage: npx tsx generate_form.ts <schema-file> <output-file>');
console.log('Example: npx tsx generate_form.ts ./schemas/login.ts ./components/LoginForm.tsx');
process.exit(1);
}
// Example: Generate a login form
const exampleFields: FieldConfig[] = [
{ name: 'email', type: 'email', label: 'Email Address', placeholder: 'you@example.com' },
{ name: 'password', type: 'password', label: 'Password', placeholder: 'Enter password' },
{ name: 'rememberMe', type: 'checkbox', label: 'Remember me' }
];
const code = generateFormComponent('loginSchema', exampleFields, 'LoginForm');
console.log('Generated form component:');
console.log(code);
console.log('\nTo customize, edit the field configurations and re-run.');
}
export { generateFormComponent, inferFieldType };
#!/usr/bin/env node
/**
* Zod Schema Linter - Detects common issues in Zod schemas
*
* Usage: npx tsx validate_schemas.ts <schema-dir>
*
* Checks for:
* - Missing error messages
* - Inconsistent error message styles
* - Overly permissive validations
* - Missing optional() on nullable fields
* - Regex without examples/comments
* - No min/max constraints on strings
*
* Dependencies: npm install zod typescript
*/
import * as fs from 'fs';
import * as path from 'path';
import { z } from 'zod';
interface LintIssue {
file: string;
line?: number;
severity: 'error' | 'warning' | 'info';
message: string;
suggestion?: string;
}
class SchemaLinter {
private issues: LintIssue[] = [];
/**
* Lint a Zod schema definition (source code analysis)
*/
lintSchemaFile(filePath: string): void {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
lines.forEach((line, index) => {
const lineNumber = index + 1;
// Check 1: String validation without error message
if (line.match(/z\.string\(\)\.email\(\)(?!\()/)) {
this.issues.push({
file: filePath,
line: lineNumber,
severity: 'warning',
message: 'email() validation missing custom error message',
suggestion: ".email('Invalid email address')"
});
}
if (line.match(/z\.string\(\)\.min\(\d+\)(?!\()/)) {
this.issues.push({
file: filePath,
line: lineNumber,
severity: 'warning',
message: 'min() validation missing custom error message',
suggestion: ".min(8, 'Must be at least 8 characters')"
});
}
// Check 2: Regex without comment
if (line.match(/\.regex\(\/.*\//) && !lines[index - 1]?.includes('//')) {
this.issues.push({
file: filePath,
line: lineNumber,
severity: 'info',
message: 'Complex regex should have explanatory comment',
suggestion: 'Add comment above explaining what the regex validates'
});
}
// Check 3: String without constraints
if (line.match(/:\s*z\.string\(\),?\s*$/) && !line.includes('optional')) {
this.issues.push({
file: filePath,
line: lineNumber,
severity: 'info',
message: 'String field without min/max constraints',
suggestion: 'Consider adding .min() or .max() for data integrity'
});
}
// Check 4: Number without constraints
if (line.match(/:\s*z\.number\(\),?\s*$/) && !line.includes('optional')) {
this.issues.push({
file: filePath,
line: lineNumber,
severity: 'info',
message: 'Number field without min/max constraints',
suggestion: 'Consider adding .min() or .max() for validation'
});
}
// Check 5: Nullable without optional
if (line.includes('.nullable()') && !line.includes('.optional()')) {
this.issues.push({
file: filePath,
line: lineNumber,
severity: 'warning',
message: 'nullable() without optional() - may cause confusion',
suggestion: 'Use .optional() for optional fields, .nullable() for null values'
});
}
// Check 6: Array without min constraint
if (line.match(/z\.array\(/) && !content.slice(content.indexOf(line)).match(/\.min\(\d+\)/)) {
this.issues.push({
file: filePath,
line: lineNumber,
severity: 'info',
message: 'Array without minimum length validation',
suggestion: "Consider .min(1, 'At least one item required')"
});
}
// Check 7: Password field without length requirement
if (line.includes('password') && line.match(/z\.string\(\)/) && !line.includes('.min(')) {
this.issues.push({
file: filePath,
line: lineNumber,
severity: 'error',
message: 'Password field without minimum length requirement',
suggestion: ".min(8, 'Password must be at least 8 characters')"
});
}
// Check 8: Email field not using .email()
if (line.includes('email') && line.match(/z\.string\(\)/) && !line.includes('.email()')) {
this.issues.push({
file: filePath,
line: lineNumber,
severity: 'error',
message: 'Email field not using .email() validation',
suggestion: '.email() for built-in email validation'
});
}
});
}
/**
* Scan directory for schema files
*/
lintDirectory(dirPath: string): void {
const files = fs.readdirSync(dirPath);
files.forEach(file => {
const fullPath = path.join(dirPath, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
this.lintDirectory(fullPath);
} else if (file.endsWith('.ts') && (file.includes('schema') || file.includes('validation'))) {
this.lintSchemaFile(fullPath);
}
});
}
/**
* Print results
*/
report(): void {
if (this.issues.length === 0) {
console.log('✅ No issues found! Schemas look good.');
return;
}
const errors = this.issues.filter(i => i.severity === 'error');
const warnings = this.issues.filter(i => i.severity === 'warning');
const info = this.issues.filter(i => i.severity === 'info');
console.log(`\n📋 Schema Validation Report\n`);
console.log(`Found ${errors.length} errors, ${warnings.length} warnings, ${info.length} suggestions\n`);
const printIssues = (issues: LintIssue[], icon: string, color: string) => {
if (issues.length === 0) return;
issues.forEach(issue => {
console.log(`${icon} ${issue.file}:${issue.line || '?'}`);
console.log(` ${issue.message}`);
if (issue.suggestion) {
console.log(` 💡 ${issue.suggestion}`);
}
console.log('');
});
};
if (errors.length > 0) {
console.log('🚨 Errors:\n');
printIssues(errors, '❌', 'red');
}
if (warnings.length > 0) {
console.log('⚠️ Warnings:\n');
printIssues(warnings, '⚠️ ', 'yellow');
}
if (info.length > 0) {
console.log('💡 Suggestions:\n');
printIssues(info, 'ℹ️ ', 'blue');
}
if (errors.length > 0) {
process.exit(1);
}
}
}
// CLI entry point
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log('Usage: npx tsx validate_schemas.ts <schema-directory>');
console.log('Example: npx tsx validate_schemas.ts ./src/schemas');
process.exit(1);
}
const dirPath = args[0];
if (!fs.existsSync(dirPath)) {
console.error(`❌ Directory not found: ${dirPath}`);
process.exit(1);
}
const linter = new SchemaLinter();
linter.lintDirectory(dirPath);
linter.report();
}
export { SchemaLinter };