for (const [name, field] of Object.entries(newMeta)) {
const validator = validators[name];
if (!validator) continue;
const result = validator(field.value);
const violation = result instanceof Promise ? await result : result;
newMeta[name] = { ...field, violation };
if (violation) hasViolation = true;
}
setMeta(newMeta);
return !hasViolation;
}, [meta, validators]);
const focusFirstError = useCallback(() => {
const firstErrorField = Object.entries(meta).find(([, field]) => field.violation);
if (firstErrorField) {
const element = document.getElementById(firstErrorField[0]);
element?.focus();
errorFieldRef.current = firstErrorField[0];
}
}, [meta]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setGlobalError(null);
const isValid = await runValidation();
if (!isValid) {
focusFirstError();
setIsSubmitting(false);
return;
}
try {
const data = Object.fromEntries(
Object.entries(meta).map(([name, field]) => [name, field.value])
);
await onSubmit(data);
} catch (err) {
setGlobalError('Submission failed. Please check your connection and try again.');
} finally {
setIsSubmitting(false);
}
}, [meta, runValidation, focusFirstError, onSubmit]);
return { meta, isSubmitting, globalError, updateField, handleSubmit, focusFirstError };
}
**Rationale:**
* **Centralized State:** `meta` tracks value, touch state, violations, and async status. This enables derived rendering logic without prop drilling.
* **Deterministic Validation:** `runValidation` iterates fields synchronously or awaits async validators, ensuring consistent state updates.
* **Focus Management:** `focusFirstError` programmatically moves focus to the first invalid field, reducing cognitive load for keyboard users.
* **Global Error Handling:** Catches submission failures and exposes a top-level error message via `role="alert"`.
#### 2. Composable Input Wrapper
Create a wrapper component that enforces semantic labeling, error association, and ARIA wiring. This component consumes field metadata and renders the appropriate attributes automatically.
```typescript
import { useId } from 'react';
type InputWrapperProps = {
name: string;
label: string;
hint?: string;
violation?: string | null;
children: React.ReactElement;
};
export function InputWrapper({ name, label, hint, violation, children }: InputWrapperProps) {
const inputId = useId();
const hintId = `${inputId}-hint`;
const errorId = `${inputId}-error`;
const describedBy = [
hint ? hintId : null,
violation ? errorId : null,
].filter(Boolean).join(' ') || undefined;
return (
<div className="form-field">
<label htmlFor={inputId} className="form-label">
{label}
</label>
{React.cloneElement(children, {
id: inputId,
name: name,
'aria-invalid': violation ? true : undefined,
'aria-describedby': describedBy,
'aria-required': children.props.required ? true : undefined,
})}
{hint && (
<div id={hintId} className="form-hint">
{hint}
</div>
)}
{violation && (
<div id={errorId} role="alert" className="form-error">
{violation}
</div>
)}
</div>
);
}
Rationale:
useId for Stability: Generates unique, SSR-safe IDs, preventing hydration mismatches and ID collisions.
- Automatic Association:
aria-describedby dynamically links hints and errors to the input. Screen readers announce these associations when the field receives focus.
role="alert": Errors use role="alert" to trigger immediate screen reader announcements without requiring focus movement.
- Composition:
React.cloneElement injects ARIA attributes into the child input, keeping the wrapper generic and reusable.
3. Field Primitives
Implement specific field types that leverage the wrapper. Ensure groups use fieldset and legend for semantic context.
type TextInputProps = {
type?: string;
required?: boolean;
value: string;
onChange: (value: string) => void;
};
export function TextInput({ type = 'text', required, value, onChange, ...rest }: TextInputProps) {
return (
<input
type={type}
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
className="form-input"
{...rest}
/>
);
}
type CheckboxGroupProps = {
name: string;
label: string;
options: { value: string; label: string }[];
value: string[];
onChange: (values: string[]) => void;
violation?: string | null;
};
export function CheckboxGroup({ name, label, options, value, onChange, violation }: CheckboxGroupProps) {
const toggle = (val: string) => {
const next = value.includes(val)
? value.filter((v) => v !== val)
: [...value, val];
onChange(next);
};
return (
<fieldset className="form-group">
<legend className="form-legend">{label}</legend>
{options.map((opt) => (
<label key={opt.value} className="form-checkbox-label">
<input
type="checkbox"
name={name}
value={opt.value}
checked={value.includes(opt.value)}
onChange={() => toggle(opt.value)}
aria-invalid={violation ? true : undefined}
/>
{opt.label}
</label>
))}
{violation && (
<div role="alert" className="form-error">
{violation}
</div>
)}
</fieldset>
);
}
Rationale:
fieldset and legend: Groups related checkboxes or radios, providing context to screen readers. The legend is announced when navigating the group.
- Native Controls: Uses standard
<input> elements to preserve browser defaults and accessibility tree integration.
- Toggle Logic:
CheckboxGroup manages selection state locally, ensuring immutable updates and predictable rendering.
4. Validation Pipeline
Implement a validation strategy that combines synchronous checks with debounced async validation. Avoid blocking user input during async checks.
function debounce<T extends (...args: any[]) => any>(fn: T, delay: number) {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const asyncUsernameCheck = debounce(async (username: string): Promise<string | null> => {
if (!username || username.length < 3) return null;
// Simulate API call
await new Promise((r) => setTimeout(r, 500));
return username === 'admin' ? 'Username is taken' : null;
}, 300);
const validators = {
username: asyncUsernameCheck,
email: (value: string | boolean) => {
const email = value as string;
if (!email) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Invalid email format';
return null;
},
// ... other validators
};
Rationale:
- Debouncing: Reduces API calls and prevents validation flicker during rapid typing.
- Non-Blocking: Async validators run in the background. The UI remains responsive, and errors appear only after validation completes.
- Type Safety: Validators accept
string | boolean to support diverse field types, returning null for valid states.
Pitfall Guide
-
ARIA Overload on Native Elements
- Explanation: Adding
role="textbox" to <input> or aria-label to a labeled <button> creates redundancy and can confuse assistive technologies.
- Fix: Rely on native semantics. Use ARIA only when native elements cannot express the required interaction or state.
-
Missing fieldset for Groups
- Explanation: Radio buttons and checkboxes without
fieldset/legend lose group context. Screen readers announce each option in isolation.
- Fix: Wrap related controls in
<fieldset> and provide a <legend> describing the group.
-
Error Association Drift
- Explanation: Hardcoded IDs in
aria-describedby break when components are reused or rendered dynamically.
- Fix: Use
useId to generate stable IDs and construct aria-describedby dynamically based on rendered hints and errors.
-
Color-Only Validation Indicators
- Explanation: Relying solely on red borders for errors excludes color-blind users and fails WCAG contrast requirements.
- Fix: Combine color with text messages, icons, and
aria-invalid attributes. Ensure error text is explicitly associated via aria-describedby.
-
Focus Traps in Modals
- Explanation: Trapping focus within a form modal without an escape mechanism frustrates keyboard users.
- Fix: Implement focus trapping with a clear exit strategy (e.g.,
Escape key closes modal). Ensure the first focusable element is logical.
-
Blocking Async Validation
- Explanation: Preventing user input while checking availability degrades UX and increases abandonment.
- Fix: Use debounced async validators that run in the background. Display a loading indicator if necessary, but allow typing to continue.
-
Ignoring Progressive Enhancement
- Explanation: Forms that fail completely without JavaScript exclude users with restricted environments or slow connections.
- Fix: Ensure forms submit via native
<form action> as a fallback. Use progressive enhancement to layer React behavior on top of functional HTML.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Contact Form | Native HTML + Minimal React | Low complexity; native semantics suffice. Reduces bundle size. | Low |
| Complex Multi-Step Wizard | useFormOrchestrator + Primitives | Centralized state and validation logic required. Ensures consistency across steps. | Medium |
| High-Traffic E-Commerce | Orchestrated Architecture + Performance Optimizations | Accessibility directly impacts conversion. Debounced validation reduces server load. | High ROI |
| Legacy Codebase Migration | Incremental Wrapper Adoption | Wrap existing inputs with InputWrapper to add ARIA without rewriting state logic. | Low |
| Real-Time Collaboration Form | Optimistic Updates + Conflict Resolution | Requires advanced state management. Accessibility patterns remain consistent. | High |
Configuration Template
// form.config.ts
import { useFormOrchestrator } from './hooks/useFormOrchestrator';
export const useSignUpForm = () => {
const initialMeta = {
username: { value: '', isTouched: false, violation: null, isAsyncValidating: false },
email: { value: '', isTouched: false, violation: null, isAsyncValidating: false },
password: { value: '', isTouched: false, violation: null, isAsyncValidating: false },
terms: { value: false, isTouched: false, violation: null, isAsyncValidating: false },
};
const validators = {
username: async (value: string | boolean) => {
const v = value as string;
if (!v) return 'Username is required';
if (v.length < 3) return 'Minimum 3 characters';
// Async check
return null;
},
email: (value: string | boolean) => {
const v = value as string;
if (!v) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return 'Invalid email';
return null;
},
password: (value: string | boolean) => {
const v = value as string;
if (!v) return 'Password is required';
if (v.length < 8) return 'Minimum 8 characters';
return null;
},
terms: (value: string | boolean) => {
return value ? null : 'You must agree to the terms';
},
};
const onSubmit = async (data: Record<string, string | boolean>) => {
// Submit logic
console.log('Submitting:', data);
};
return useFormOrchestrator({ initialMeta, validators, onSubmit });
};
Quick Start Guide
- Install Dependencies: Ensure React 18+ is installed. No additional libraries are required for this pattern.
- Create Orchestrator: Copy
useFormOrchestrator into your hooks directory. Define initialMeta and validators for your form.
- Wrap Inputs: Import
InputWrapper and wrap your native inputs. Pass name, label, and violation props.
- Wire Submission: Use
handleSubmit from the orchestrator on your <form onSubmit>. Ensure focusFirstError is called on validation failure.
- Test: Run keyboard navigation tests and screen reader checks. Verify error announcements and focus behavior. Iterate on validation messages for clarity.