On this page
Controlled Forms: Validation and React 19 Actions
Forms in React: Controlled vs. Uncontrolled
React offers two approaches to form handling:
- Controlled inputs: React state drives the value. You get instant access to the value for validation, transformation, and conditional logic.
- Uncontrolled inputs: The DOM stores the value. You access it via a ref on submit.
Modern React applications use controlled inputs for the majority of forms because they give you complete control and enable real-time validation.
The Controlled Input Pattern
A controlled input keeps its value in React state and updates state on every change:
import { useState, type ChangeEvent } from 'react';
function NameInput() {
const [name, setName] = useState('');
const isValid = name.trim().length >= 2;
return (
<div>
<label htmlFor="name">Your name</label>
<input
id="name"
type="text"
value={name}
onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
aria-invalid={!isValid && name.length > 0}
/>
{!isValid && name.length > 0 && (
<span role="alert">Name must be at least 2 characters.</span>
)}
</div>
);
}Building a Multi-Field Form
A practical approach for forms with multiple fields uses a single state object and a shared handleChange handler:
import { useState, type ChangeEvent, type FormEvent } from 'react';
interface ContactFormData {
subject: string;
message: string;
category: string;
urgent: boolean;
}
const INITIAL: ContactFormData = {
subject: '',
message: '',
category: 'general',
urgent: false,
};
function ContactForm() {
const [formData, setFormData] = useState<ContactFormData>(INITIAL);
const [isSubmitting, setIsSubmitting] = useState(false);
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) {
const { name, value, type } = e.target;
const checked = type === 'checkbox' ? (e.target as HTMLInputElement).checked : undefined;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
}
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsSubmitting(true);
try {
await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
setFormData(INITIAL); // reset form
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="subject">Subject</label>
<input
id="subject"
name="subject"
type="text"
value={formData.subject}
onChange={handleChange}
required
/>
</div>
<div>
<label htmlFor="category">Category</label>
<select
id="category"
name="category"
value={formData.category}
onChange={handleChange}
>
<option value="general">General inquiry</option>
<option value="billing">Billing</option>
<option value="support">Technical support</option>
</select>
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows={5}
required
/>
</div>
<div>
<input
id="urgent"
name="urgent"
type="checkbox"
checked={formData.urgent}
onChange={handleChange}
/>
<label htmlFor="urgent">Mark as urgent</label>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send message'}
</button>
</form>
);
}Validation Patterns
Validate on Submit
Validate the entire form only when the user submits. Good for simpler forms:
function validate(data: ContactFormData): string[] {
const errors: string[] = [];
if (!data.subject.trim()) errors.push('Subject is required');
if (data.message.trim().length < 20) errors.push('Message must be at least 20 characters');
return errors;
}Validate on Blur (field-level)
Validate a field when the user leaves it:
const [touched, setTouched] = useState<Set<string>>(new Set());
function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
setTouched((prev) => new Set(prev).add(e.target.name));
}
// Show error only if the field was touched
const showSubjectError = touched.has('subject') && !formData.subject.trim();React 19 Form Actions with useActionState
React 19 introduces useActionState — a hook that pairs with async action functions attached to <form action={...}>:
import { useActionState } from 'react';
type LoginState = {
errors: { email?: string; password?: string };
message: string | null;
};
async function loginAction(
_prev: LoginState,
formData: FormData
): Promise<LoginState> {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
if (!email.includes('@')) {
return { errors: { email: 'Invalid email' }, message: null };
}
if (password.length < 6) {
return { errors: { password: 'Password too short' }, message: null };
}
// Simulate API call
await new Promise((r) => setTimeout(r, 1000));
return { errors: {}, message: 'Logged in successfully!' };
}
function LoginForm() {
const [state, formAction, isPending] = useActionState(loginAction, {
errors: {},
message: null,
});
return (
<form action={formAction}>
<div>
<label htmlFor="login-email">Email</label>
<input
id="login-email"
name="email"
type="email"
required
aria-invalid={!!state.errors.email}
aria-describedby={state.errors.email ? 'email-err' : undefined}
/>
{state.errors.email && (
<span id="email-err" role="alert">{state.errors.email}</span>
)}
</div>
<div>
<label htmlFor="login-password">Password</label>
<input
id="login-password"
name="password"
type="password"
required
aria-invalid={!!state.errors.password}
/>
{state.errors.password && (
<span role="alert">{state.errors.password}</span>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Logging in...' : 'Log in'}
</button>
{state.message && <p role="status">{state.message}</p>}
</form>
);
}The advantage of this pattern is that it works progressively — in frameworks like Next.js with the App Router, the same form action can run on the server even without JavaScript enabled.
useOptimistic for Instant Feedback
React 19 also introduces useOptimistic for displaying optimistic state while an action is pending:
import { useOptimistic, useActionState } from 'react';
interface Comment {
id: number;
text: string;
pending?: boolean;
}
function CommentSection({ initialComments }: { initialComments: Comment[] }) {
const [comments, setComments] = useState(initialComments);
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(state, newComment: Comment) => [...state, newComment]
);
async function handleAddComment(formData: FormData) {
const text = formData.get('text') as string;
const tempComment: Comment = { id: -1, text, pending: true };
addOptimistic(tempComment); // instantly show in UI
const saved = await fetch('/api/comments', {
method: 'POST',
body: JSON.stringify({ text }),
headers: { 'Content-Type': 'application/json' },
}).then((r) => r.json() as Promise<Comment>);
setComments((prev) => [...prev.filter((c) => c.id !== -1), saved]);
}
return (
<div>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.pending ? 0.6 : 1 }}>
{c.text} {c.pending && '(saving...)'}
</li>
))}
</ul>
<form action={handleAddComment}>
<input name="text" type="text" placeholder="Add a comment..." required />
<button type="submit">Post</button>
</form>
</div>
);
}In the next lesson, you will learn about professional data fetching patterns with TanStack Query — caching, invalidation, mutations, and pagination.
import { useState, type ChangeEvent, type FormEvent } from 'react';
interface RegisterFormData {
name: string;
email: string;
password: string;
confirmPassword: string;
}
type FormErrors = Partial<Record<keyof RegisterFormData, string>>;
function validate(data: RegisterFormData): FormErrors {
const errors: FormErrors = {};
if (!data.name.trim()) {
errors.name = 'Name is required';
}
if (!data.email.includes('@')) {
errors.email = 'Enter a valid email address';
}
if (data.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (data.password !== data.confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
return errors;
}
function RegisterForm() {
const [formData, setFormData] = useState<RegisterFormData>({
name: '',
email: '',
password: '',
confirmPassword: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [submitted, setSubmitted] = useState(false);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Clear error on change
if (errors[name as keyof RegisterFormData]) {
setErrors((prev) => ({ ...prev, [name]: undefined }));
}
}
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const validationErrors = validate(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
await new Promise((r) => setTimeout(r, 500)); // simulate API
setSubmitted(true);
}
if (submitted) {
return <p role="status">Registration successful! Welcome, {formData.name}.</p>;
}
return (
<form onSubmit={handleSubmit} noValidate>
<div className="field">
<label htmlFor="name">Full name</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
aria-describedby={errors.name ? 'name-error' : undefined}
aria-invalid={!!errors.name}
autoComplete="name"
/>
{errors.name && (
<span id="name-error" role="alert" className="error">
{errors.name}
</span>
)}
</div>
<div className="field">
<label htmlFor="email">Email address</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
autoComplete="email"
/>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
<button type="submit">Create account</button>
</form>
);
}
export default RegisterForm;
Sign in to track your progress