Handling Forms and User Input
Learn how to handle forms, controlled components, validation, and user interactions in React.
Topics Covered:
Prerequisites:
- Managing State with useState
Video Tutorial
Overview
Forms are central to most web applications. This tutorial covers both traditional form handling with controlled components and modern React 19 patterns using useActionState and useFormStatus. You'll learn how to create forms with validation, handle submissions, manage form state, and leverage React 19's powerful form features for better developer experience and user experience.
Lesson 1: Understanding Controlled Components
Controlled components are the foundation of React form handling. Understanding them is essential before moving to React 19 patterns. What are Controlled Components? • Input value is controlled by React state • onChange handler updates state • State is the single source of truth • Enables validation and transformation Benefits: • Predictable form state • Easy validation • Full control over input values • Can transform values before setting state vs Uncontrolled Components: • Controlled: Value in state, onChange updates state • Uncontrolled: Value in DOM, use ref to access
// Controlled component
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
// Uncontrolled component
function UncontrolledInput() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
const value = inputRef.current?.value;
console.log(value);
};
return (
<input
ref={inputRef}
defaultValue="initial"
/>
);
}
// Controlled form example
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log({ email, password });
// Submit form
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}
// Why controlled?
// ✅ Can validate in real-time
// ✅ Can transform values (uppercase, format)
// ✅ Can disable submit button based on state
// ✅ Can reset form easily
// ✅ Can access values without refsControlled components give you full control over form state. The value comes from state, and onChange updates state. This enables validation, transformation, and better form management.
Lesson 2: Form Validation Patterns
Validation ensures users enter correct data. Learn different validation approaches. Validation Types: • Client-side validation (immediate feedback) • Server-side validation (security) • Real-time validation (as user types) • On-submit validation (before sending) Best Practices: • Validate on both client and server • Show clear error messages • Don't block user input • Validate on blur for better UX • Use HTML5 validation attributes
// Real-time validation
function EmailInput() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
// Validate as user types
if (value && !value.includes('@')) {
setError('Please enter a valid email');
} else {
setError('');
}
};
return (
<div>
<input
type="email"
value={email}
onChange={handleChange}
className={error ? 'error' : ''}
/>
{error && <span className="error-text">{error}</span>}
</div>
);
}
// Validation on blur (better UX)
function EmailInputBlur() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [touched, setTouched] = useState(false);
const validate = (value: string) => {
if (!value) return 'Email is required';
if (!value.includes('@')) return 'Invalid email format';
return '';
};
const handleBlur = () => {
setTouched(true);
setError(validate(email));
};
return (
<div>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={handleBlur}
className={touched && error ? 'error' : ''}
/>
{touched && error && (
<span className="error-text">{error}</span>
)}
</div>
);
}
// Multiple field validation
function RegistrationForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (name: string, value: string) => {
switch (name) {
case 'email':
if (!value.includes('@')) return 'Invalid email';
break;
case 'password':
if (value.length < 8) return 'Password must be 8+ characters';
break;
case 'name':
if (value.length < 2) return 'Name must be 2+ characters';
break;
}
return '';
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Validate field
const error = validate(name, value);
setErrors(prev => ({ ...prev, [name]: error }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const newErrors: Record<string, string> = {};
Object.entries(formData).forEach(([key, value]) => {
const error = validate(key, value);
if (error) newErrors[key] = error;
});
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
// Submit form
console.log('Form valid:', formData);
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={formData.name}
onChange={handleChange}
className={errors.name ? 'error' : ''}
/>
{errors.name && <span>{errors.name}</span>}
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
className={errors.email ? 'error' : ''}
/>
{errors.email && <span>{errors.email}</span>}
<input
name="password"
type="password"
value={formData.password}
onChange={handleChange}
className={errors.password ? 'error' : ''}
/>
{errors.password && <span>{errors.password}</span>}
<button type="submit">Register</button>
</form>
);
}
// HTML5 validation
function HTML5Form() {
return (
<form>
<input
type="email"
required
pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"
title="Enter a valid email address"
/>
<input
type="password"
required
minLength={8}
title="Password must be at least 8 characters"
/>
<button type="submit">Submit</button>
</form>
);
}Validation can happen in real-time, on blur, or on submit. Combine client-side validation with server-side validation for security. Show clear error messages and don't block user input.
Lesson 3: React 19 - Server Actions and useActionState
React 19 introduces useActionState (formerly useFormState) for managing forms with server actions. This is the modern way to handle forms in React 19 and Next.js. What are Server Actions? • Async functions that run on the server • Called directly from client components • Type-safe end-to-end • No API routes needed useActionState Benefits: • Built-in pending state • Automatic form state management • Works with Server Actions • Better TypeScript support • Simplified form handling When to Use: • Forms with server-side processing • Next.js App Router applications • When you want built-in pending state • Type-safe form handling
// Step 1: Create Server Action
// app/actions.ts
'use server';
export interface FormState {
message?: string;
error?: string;
success?: boolean;
}
export async function submitContactForm(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Validate
if (!email || !email.includes('@')) {
return {
error: 'Please enter a valid email address',
success: false,
};
}
if (!message || message.trim().length < 10) {
return {
error: 'Message must be at least 10 characters',
success: false,
};
}
// Process (save to database, send email, etc.)
try {
await saveContactForm({ email, message });
return {
message: 'Thank you for your message!',
success: true,
};
} catch (error) {
return {
error: 'Failed to send message. Please try again.',
success: false,
};
}
}
// Step 2: Use in Component
'use client';
import { useActionState } from 'react';
import { submitContactForm } from '@/app/actions';
const initialState: FormState = {};
function ContactForm() {
const [state, formAction, pending] = useActionState(
submitContactForm,
initialState
);
return (
<form action={formAction}>
<input
name="email"
type="email"
required
disabled={pending}
/>
<textarea
name="message"
required
disabled={pending}
minLength={10}
/>
{state.error && (
<div className="error">{state.error}</div>
)}
{state.message && (
<div className="success">{state.message}</div>
)}
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
// useActionState returns:
// - state: Current form state (from server action)
// - formAction: Action wrapper for form
// - pending: Boolean indicating if action is in progress
// Field-level errors
interface RegistrationState {
errors?: {
email?: string;
password?: string;
name?: string;
};
message?: string;
}
'use server';
export async function registerUser(
prevState: RegistrationState,
formData: FormData
): Promise<RegistrationState> {
const errors: RegistrationState['errors'] = {};
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const name = formData.get('name') as string;
// Validate each field
if (!email || !email.includes('@')) {
errors.email = 'Invalid email address';
}
if (!password || password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (!name || name.length < 2) {
errors.name = 'Name must be at least 2 characters';
}
if (Object.keys(errors).length > 0) {
return { errors };
}
// Create user
await createUser({ email, password, name });
return { message: 'Account created successfully!' };
}
// Using field-level errors
function RegistrationForm() {
const [state, formAction, pending] = useActionState(
registerUser,
{}
);
return (
<form action={formAction}>
<div>
<input name="name" disabled={pending} />
{state.errors?.name && (
<span className="error">{state.errors.name}</span>
)}
</div>
<div>
<input name="email" type="email" disabled={pending} />
{state.errors?.email && (
<span className="error">{state.errors.email}</span>
)}
</div>
<div>
<input name="password" type="password" disabled={pending} />
{state.errors?.password && (
<span className="error">{state.errors.password}</span>
)}
</div>
{state.message && (
<div className="success">{state.message}</div>
)}
<button type="submit" disabled={pending}>
{pending ? 'Creating Account...' : 'Register'}
</button>
</form>
);
}useActionState simplifies form handling with server actions. It provides built-in pending state and automatic form state management. Server actions run on the server, providing security and type safety.
Lesson 4: useFormStatus for Form State Access
useFormStatus lets child components access form submission status without prop drilling. Key Features: • Automatically detects form status • No props needed • Works with Server Actions • Must be used inside <form> element Benefits: • Eliminates prop drilling • Better component composition • Cleaner code • Automatic status detection When to Use: • Submit buttons • Form inputs that need to disable during submission • Loading indicators • Form validation messages
import { useFormStatus } from 'react-dom';
import { useActionState } from 'react';
// Server action
'use server';
export async function submitForm(
prevState: any,
formData: FormData
) {
await new Promise(resolve => setTimeout(resolve, 2000));
return { message: 'Submitted!' };
}
// Form component
function ContactForm() {
const [state, formAction] = useActionState(submitForm, {});
return (
<form action={formAction}>
<FormInput name="email" type="email" />
<FormInput name="message" type="textarea" />
{/* Child components automatically know form status */}
<SubmitButton>Send Message</SubmitButton>
{state.message && <SuccessMessage>{state.message}</SuccessMessage>}
</form>
);
}
// SubmitButton uses useFormStatus (no props needed!)
function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? (
<>
<Spinner />
Submitting...
</>
) : (
children
)}
</button>
);
}
// FormInput also uses useFormStatus
function FormInput({
name,
type = 'text',
...props
}: {
name: string;
type?: string;
}) {
const { pending } = useFormStatus();
if (type === 'textarea') {
return (
<textarea
name={name}
disabled={pending}
{...props}
/>
);
}
return (
<input
name={name}
type={type}
disabled={pending}
{...props}
/>
);
}
// Form-wide loading overlay
function FormOverlay() {
const { pending } = useFormStatus();
if (!pending) return null;
return (
<div className="form-overlay">
<Spinner />
<p>Submitting form...</p>
</div>
);
}
// Complete form example
function CompleteForm() {
const [state, formAction] = useActionState(submitForm, {});
return (
<form action={formAction}>
<FormOverlay />
<FormInput name="name" required />
<FormInput name="email" type="email" required />
<FormInput name="message" type="textarea" required />
<SubmitButton>Submit</SubmitButton>
{state.error && <ErrorMessage>{state.error}</ErrorMessage>}
{state.message && <SuccessMessage>{state.message}</SuccessMessage>}
</form>
);
}
// useFormStatus provides:
// - pending: Boolean - true when form is submitting
// - data: FormData - current form data
// - method: 'get' | 'post' - HTTP method
// - action: string | Function - form action
// Accessing form data
function FormDataPreview() {
const { pending, data } = useFormStatus();
if (!pending || !data) return null;
return (
<div className="form-preview">
<p>Submitting:</p>
<ul>
{Array.from(data.entries()).map(([key, value]) => (
<li key={key}>
{key}: {String(value)}
</li>
))}
</ul>
</div>
);
}useFormStatus eliminates prop drilling by automatically detecting form submission status. Child components can access pending state, form data, and other form information without receiving props.
Lesson 5: Combining Traditional and React 19 Patterns
You can combine traditional controlled components with React 19 hooks for maximum flexibility. Hybrid Approach: • Use controlled components for complex validation • Use useActionState for submission • Use useFormStatus for child components • Combine client and server validation Best Practices: • Validate on client for UX • Validate on server for security • Use controlled components for complex forms • Use useActionState for simple forms • Leverage useFormStatus to avoid prop drilling
// Hybrid: Controlled inputs + useActionState
function AdvancedForm() {
// Traditional controlled state for complex validation
const [formData, setFormData] = useState({
email: '',
password: '',
});
const [clientErrors, setClientErrors] = useState<Record<string, string>>({});
// React 19 for submission
const [serverState, formAction, pending] = useActionState(
submitForm,
{}
);
// Client-side validation
const validateField = (name: string, value: string) => {
const errors: Record<string, string> = {};
if (name === 'email' && value && !value.includes('@')) {
errors.email = 'Invalid email';
}
if (name === 'password' && value && value.length < 8) {
errors.password = 'Password too short';
}
setClientErrors(prev => ({ ...prev, ...errors }));
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
validateField(name, value);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Client-side validation before submit
if (Object.keys(clientErrors).length > 0) {
return;
}
// Convert to FormData for server action
const formDataObj = new FormData();
formDataObj.append('email', formData.email);
formDataObj.append('password', formData.password);
// Submit with server action
await formAction(formDataObj);
};
return (
<form onSubmit={handleSubmit}>
<input
name="email"
value={formData.email}
onChange={handleChange}
disabled={pending}
/>
{clientErrors.email && (
<span className="error">{clientErrors.email}</span>
)}
{serverState.errors?.email && (
<span className="error">{serverState.errors.email}</span>
)}
<input
name="password"
type="password"
value={formData.password}
onChange={handleChange}
disabled={pending}
/>
{clientErrors.password && (
<span className="error">{clientErrors.password}</span>
)}
<SubmitButton />
</form>
);
}
// SubmitButton uses useFormStatus
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
// Progressive enhancement pattern
function ProgressiveForm() {
// Works without JavaScript (HTML5 validation)
// Enhanced with React for better UX
const [state, formAction, pending] = useActionState(submitForm, {});
return (
<form action={formAction}>
<input
name="email"
type="email"
required
pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"
disabled={pending}
/>
<SubmitButton />
{/* Enhanced with React */}
{state.error && <ErrorMessage>{state.error}</ErrorMessage>}
{state.message && <SuccessMessage>{state.message}</SuccessMessage>}
</form>
);
}Combine traditional controlled components with React 19 hooks for maximum flexibility. Use controlled components for complex client-side validation and useActionState for server-side processing.
Lesson 6: Form State Management Patterns
Learn different patterns for managing form state effectively. State Management Options: • Individual useState for each field • Single useState object for all fields • useReducer for complex forms • useActionState for server actions • Custom hooks for reusable logic Choosing the Right Pattern: • Simple forms: Individual useState or object • Complex forms: useReducer • Server actions: useActionState • Reusable logic: Custom hooks
// Pattern 1: Individual useState (simple forms)
function SimpleForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
</form>
);
}
// Pattern 2: Single state object (better for multiple fields)
function ObjectForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: '',
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
return (
<form>
<input
name="name"
value={formData.name}
onChange={handleChange}
/>
<input
name="email"
value={formData.email}
onChange={handleChange}
/>
</form>
);
}
// Pattern 3: useReducer (complex forms)
type FormAction =
| { type: 'SET_FIELD'; field: string; value: string }
| { type: 'SET_ERRORS'; errors: Record<string, string> }
| { type: 'RESET' };
function reducer(state: any, action: FormAction) {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'SET_ERRORS':
return { ...state, errors: action.errors };
case 'RESET':
return initialState;
default:
return state;
}
}
function ComplexForm() {
const [state, dispatch] = useReducer(reducer, {
name: '',
email: '',
errors: {},
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({
type: 'SET_FIELD',
field: e.target.name,
value: e.target.value,
});
};
return (
<form>
<input
name="name"
value={state.name}
onChange={handleChange}
/>
</form>
);
}
// Pattern 4: Custom hook for reusable form logic
function useForm<T extends Record<string, any>>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const setValue = (name: keyof T, value: any) => {
setValues(prev => ({ ...prev, [name]: value }));
// Clear error when field changes
if (errors[name]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const setError = (name: keyof T, error: string) => {
setErrors(prev => ({ ...prev, [name]: error }));
};
const reset = () => {
setValues(initialValues);
setErrors({});
};
return {
values,
errors,
setValue,
setError,
reset,
};
}
// Using custom hook
function FormWithHook() {
const { values, errors, setValue, setError } = useForm({
email: '',
password: '',
});
return (
<form>
<input
value={values.email}
onChange={e => setValue('email', e.target.value)}
/>
{errors.email && <span>{errors.email}</span>}
</form>
);
}
// Pattern 5: React 19 with useActionState
function ModernForm() {
const [state, formAction, pending] = useActionState(submitForm, {});
// Simpler - state managed by useActionState
return (
<form action={formAction}>
<input name="email" disabled={pending} />
{state.errors?.email && <span>{state.errors.email}</span>}
<button type="submit" disabled={pending}>Submit</button>
</form>
);
}Choose the right state management pattern based on form complexity. Simple forms can use individual useState, complex forms benefit from useReducer, and React 19 forms can use useActionState for built-in state management.
Lesson 7: Real-World Form Examples
See complete, production-ready form examples using React 19 patterns. Example Forms: • Contact form • Registration form • Login form • Search form • Multi-step form Each example demonstrates: • Proper validation • Error handling • Loading states • Success feedback • Accessibility
// Example 1: Contact Form with React 19
'use server';
export async function submitContact(
prevState: any,
formData: FormData
) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
if (!name || name.trim().length < 2) {
return { error: 'Name must be at least 2 characters' };
}
if (!email || !email.includes('@')) {
return { error: 'Invalid email address' };
}
if (!message || message.trim().length < 10) {
return { error: 'Message must be at least 10 characters' };
}
await sendEmail({ name, email, message });
return { message: 'Message sent successfully!' };
}
function ContactForm() {
const [state, formAction, pending] = useActionState(submitContact, {});
return (
<form action={formAction} className="contact-form">
<FormInput
name="name"
label="Name"
required
minLength={2}
/>
<FormInput
name="email"
type="email"
label="Email"
required
/>
<FormTextarea
name="message"
label="Message"
required
minLength={10}
/>
{state.error && (
<ErrorMessage>{state.error}</ErrorMessage>
)}
{state.message && (
<SuccessMessage>{state.message}</SuccessMessage>
)}
<SubmitButton>Send Message</SubmitButton>
</form>
);
}
// Reusable form components
function FormInput({
name,
label,
type = 'text',
...props
}: {
name: string;
label: string;
type?: string;
}) {
const { pending } = useFormStatus();
return (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
disabled={pending}
{...props}
/>
</div>
);
}
function FormTextarea({
name,
label,
...props
}: {
name: string;
label: string;
}) {
const { pending } = useFormStatus();
return (
<div>
<label htmlFor={name}>{label}</label>
<textarea
id={name}
name={name}
disabled={pending}
{...props}
/>
</div>
);
}
function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? (
<>
<Spinner />
Submitting...
</>
) : (
children
)}
</button>
);
}
// Example 2: Multi-step form
function MultiStepForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({
personal: { name: '', email: '' },
address: { street: '', city: '' },
payment: { card: '', cvv: '' },
});
const [state, formAction, pending] = useActionState(submitForm, {});
const handleNext = () => {
setStep(s => s + 1);
};
const handleBack = () => {
setStep(s => s - 1);
};
return (
<form action={formAction}>
{step === 1 && (
<PersonalInfoStep
data={formData.personal}
onChange={(data) => setFormData(prev => ({
...prev,
personal: data,
}))}
/>
)}
{step === 2 && (
<AddressStep
data={formData.address}
onChange={(data) => setFormData(prev => ({
...prev,
address: data,
}))}
/>
)}
{step === 3 && (
<PaymentStep
data={formData.payment}
onChange={(data) => setFormData(prev => ({
...prev,
payment: data,
}))}
/>
)}
<div className="form-actions">
{step > 1 && (
<button type="button" onClick={handleBack} disabled={pending}>
Back
</button>
)}
{step < 3 ? (
<button type="button" onClick={handleNext}>
Next
</button>
) : (
<SubmitButton>Submit</SubmitButton>
)}
</div>
</form>
);
}
// Example 3: Search form with debouncing
function SearchForm() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then(setResults);
}
}, [debouncedQuery]);
return (
<form>
<input
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{results.length > 0 && (
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
)}
</form>
);
}Real-world forms combine validation, error handling, loading states, and user feedback. Use React 19 hooks for simpler, more maintainable form code. Extract reusable form components for consistency.
Lesson 8: Accessibility and Best Practices
Accessible forms are essential for all users. Learn best practices. Accessibility Requirements: • Proper labels for all inputs • Error messages associated with fields • Keyboard navigation • Screen reader support • Focus management • ARIA attributes Best Practices: • Use semantic HTML • Associate labels with inputs • Provide clear error messages • Use proper input types • Support keyboard navigation • Test with screen readers
// Accessible form example
function AccessibleForm() {
const [state, formAction, pending] = useActionState(submitForm, {});
return (
<form action={formAction} aria-label="Contact form">
<div>
<label htmlFor="name">
Name <span aria-label="required">*</span>
</label>
<input
id="name"
name="name"
type="text"
required
aria-required="true"
aria-invalid={!!state.errors?.name}
aria-describedby={state.errors?.name ? 'name-error' : undefined}
disabled={pending}
/>
{state.errors?.name && (
<span id="name-error" role="alert" className="error">
{state.errors.name}
</span>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
required
aria-required="true"
aria-invalid={!!state.errors?.email}
aria-describedby={state.errors?.email ? 'email-error' : undefined}
disabled={pending}
/>
{state.errors?.email && (
<span id="email-error" role="alert" className="error">
{state.errors.email}
</span>
)}
</div>
<button
type="submit"
disabled={pending}
aria-busy={pending}
>
{pending ? 'Submitting...' : 'Submit'}
</button>
{state.message && (
<div role="status" aria-live="polite">
{state.message}
</div>
)}
</form>
);
}
// Accessible form components
function AccessibleInput({
id,
label,
error,
...props
}: {
id: string;
label: string;
error?: string;
}) {
const { pending } = useFormStatus();
return (
<div>
<label htmlFor={id}>
{label}
{props.required && <span aria-label="required">*</span>}
</label>
<input
id={id}
aria-invalid={!!error}
aria-describedby={error ? `${id}-error` : undefined}
disabled={pending}
{...props}
/>
{error && (
<span
id={`${id}-error`}
role="alert"
className="error"
>
{error}
</span>
)}
</div>
);
}
// Form validation feedback
function FormField({
id,
label,
error,
children
}: {
id: string;
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
<label htmlFor={id}>{label}</label>
{children}
{error && (
<span
id={`${id}-error`}
role="alert"
aria-live="polite"
className="error"
>
{error}
</span>
)}
</div>
);
}
// Keyboard navigation
function KeyboardAccessibleForm() {
const formRef = useRef<HTMLFormElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Escape to reset form
if (e.key === 'Escape') {
formRef.current?.reset();
}
};
return (
<form
ref={formRef}
onKeyDown={handleKeyDown}
aria-label="Form with keyboard shortcuts"
>
{/* Form fields */}
</form>
);
}Accessible forms use proper labels, ARIA attributes, error associations, and keyboard navigation. Always test with screen readers and ensure all users can use your forms effectively.
Conclusion
Form handling in React has evolved significantly with React 19. Use controlled components for complex validation, useActionState for server-side processing, and useFormStatus to eliminate prop drilling. Always validate on both client and server, provide clear feedback, and ensure your forms are accessible to all users.