|$ curl https://forge-ai.dev/api/markdown?path=docs/react/forms
$cat docs/react-forms.md
updated Recently·35 min read·published

React Forms

ReactFormsUXBeginner to Advanced🎯Free Tools
Introduction

Forms are the primary way users interact with web applications — logging in, submitting data, searching, filtering, and configuring settings. React handles forms differently from plain HTML because form elements maintain their own internal state. React provides two approaches: controlled components (React manages state) and uncontrolled components (DOM manages state).

Controlled components are the recommended approach. They give React full control over form data, making it easy to validate, transform, and submit form values. This guide covers both approaches, validation strategies, and production-ready form libraries.

Controlled Components

In a controlled component, form data is handled by React state. The input's value is controlled by state, and every change updates that state via an onChange handler. React is the "single source of truth" for the form data.

controlled-form.jsx
JSX
1function ContactForm() {
2 const [formData, setFormData] = useState({
3 name: "",
4 email: "",
5 message: "",
6 priority: "medium",
7 newsletter: false,
8 });
9
10 const handleChange = (e) => {
11 const { name, value, type, checked } = e.target;
12 setFormData(prev => ({
13 ...prev,
14 [name]: type === "checkbox" ? checked : value,
15 }));
16 };
17
18 const handleSubmit = async (e) => {
19 e.preventDefault();
20 const response = await fetch("/api/contact", {
21 method: "POST",
22 headers: { "Content-Type": "application/json" },
23 body: JSON.stringify(formData),
24 });
25 if (response.ok) {
26 alert("Message sent!");
27 setFormData({ name: "", email: "", message: "", priority: "medium", newsletter: false });
28 }
29 };
30
31 return (
32 <form onSubmit={handleSubmit}>
33 <div>
34 <label htmlFor="name">Name</label>
35 <input
36 id="name"
37 name="name"
38 type="text"
39 value={formData.name}
40 onChange={handleChange}
41 required
42 />
43 </div>
44 <div>
45 <label htmlFor="email">Email</label>
46 <input
47 id="email"
48 name="email"
49 type="email"
50 value={formData.email}
51 onChange={handleChange}
52 required
53 />
54 </div>
55 <div>
56 <label htmlFor="message">Message</label>
57 <textarea
58 id="message"
59 name="message"
60 value={formData.message}
61 onChange={handleChange}
62 rows={5}
63 required
64 />
65 </div>
66 <div>
67 <label htmlFor="priority">Priority</label>
68 <select name="priority" value={formData.priority} onChange={handleChange}>
69 <option value="low">Low</option>
70 <option value="medium">Medium</option>
71 <option value="high">High</option>
72 </select>
73 </div>
74 <div>
75 <label>
76 <input
77 type="checkbox"
78 name="newsletter"
79 checked={formData.newsletter}
80 onChange={handleChange}
81 />
82 Subscribe to newsletter
83 </label>
84 </div>
85 <button type="submit">Send Message</button>
86 </form>
87 );
88}

info

For forms with many fields, use a single state object instead of multiple useState calls. The [name]: value pattern lets you handle any input with one handler function.
Uncontrolled Components

Uncontrolled components store form data in the DOM itself, accessed via refs. They're simpler for quick forms, file inputs, and integration with non-React code. Use them when you don't need real-time validation or dynamic form behavior.

uncontrolled-form.jsx
JSX
1import { useRef } from "react";
2
3function SimpleForm() {
4 const nameRef = useRef(null);
5 const emailRef = useRef(null);
6 const fileRef = useRef(null);
7
8 const handleSubmit = (e) => {
9 e.preventDefault();
10 const name = nameRef.current.value;
11 const email = emailRef.current.value;
12 const file = fileRef.current.files[0];
13
14 console.log({ name, email, file });
15 };
16
17 return (
18 <form onSubmit={handleSubmit}>
19 <input ref={nameRef} type="text" defaultValue="" />
20 <input ref={emailRef} type="email" defaultValue="" />
21 <input ref={fileRef} type="file" />
22 <button type="submit">Submit</button>
23 </form>
24 );
25}
26
27// File inputs are always uncontrolled in React
28function FileUpload() {
29 const fileRef = useRef(null);
30
31 const handleUpload = async () => {
32 const file = fileRef.current.files[0];
33 if (!file) return;
34
35 const formData = new FormData();
36 formData.append("file", file);
37
38 const response = await fetch("/api/upload", {
39 method: "POST",
40 body: formData,
41 });
42 return response.json();
43 };
44
45 return (
46 <div>
47 <input ref={fileRef} type="file" accept="image/*" />
48 <button onClick={handleUpload}>Upload</button>
49 </div>
50 );
51}
📝

note

File inputs (type="file") are always uncontrolled because their value is read-only from the DOM. React cannot set the value of a file input for security reasons.
Form Validation

Validation ensures users submit correct data. There are three types: HTML5 native validation, client-side validation (real-time feedback), and server-side validation (authoritative).

validation.jsx
JSX
1// Validation with custom hooks
2function useFormValidation(initialValues, validate) {
3 const [values, setValues] = useState(initialValues);
4 const [errors, setErrors] = useState({});
5 const [touched, setTouched] = useState({});
6
7 const handleChange = (e) => {
8 const { name, value } = e.target;
9 setValues(prev => ({ ...prev, [name]: value }));
10
11 // Validate on change if field was touched
12 if (touched[name]) {
13 const fieldErrors = validate({ ...values, [name]: value });
14 setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }));
15 }
16 };
17
18 const handleBlur = (e) => {
19 const { name } = e.target;
20 setTouched(prev => ({ ...prev, [name]: true }));
21
22 const fieldErrors = validate(values);
23 setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }));
24 };
25
26 const handleSubmit = (onSubmit) => (e) => {
27 e.preventDefault();
28 const allErrors = validate(values);
29 setErrors(allErrors);
30
31 if (Object.keys(allErrors).length === 0) {
32 onSubmit(values);
33 }
34 };
35
36 const reset = () => {
37 setValues(initialValues);
38 setErrors({});
39 setTouched({});
40 };
41
42 return { values, errors, touched, handleChange, handleBlur, handleSubmit, reset };
43}
44
45// Validation rules
46function validateForm(values) {
47 const errors = {};
48
49 if (!values.name?.trim()) {
50 errors.name = "Name is required";
51 } else if (values.name.length < 2) {
52 errors.name = "Name must be at least 2 characters";
53 }
54
55 if (!values.email?.trim()) {
56 errors.email = "Email is required";
57 } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
58 errors.email = "Invalid email format";
59 }
60
61 if (!values.password) {
62 errors.password = "Password is required";
63 } else {
64 if (values.password.length < 8) errors.password = "Must be at least 8 characters";
65 if (!/[A-Z]/.test(values.password)) errors.password = "Must contain an uppercase letter";
66 if (!/[0-9]/.test(values.password)) errors.password = "Must contain a number";
67 }
68
69 if (values.password !== values.confirmPassword) {
70 errors.confirmPassword = "Passwords do not match";
71 }
72
73 return errors;
74}
75
76// Using the validation hook
77function RegistrationForm() {
78 const { values, errors, touched, handleChange, handleBlur, handleSubmit } =
79 useFormValidation(
80 { name: "", email: "", password: "", confirmPassword: "" },
81 validateForm
82 );
83
84 return (
85 <form onSubmit={handleSubmit(async (data) => {
86 await registerUser(data);
87 })}>
88 <div>
89 <input name="name" value={values.name} onChange={handleChange} onBlur={handleBlur} />
90 {touched.name && errors.name && <span className="error">{errors.name}</span>}
91 </div>
92 <div>
93 <input name="email" value={values.email} onChange={handleChange} onBlur={handleBlur} />
94 {touched.email && errors.email && <span className="error">{errors.email}</span>}
95 </div>
96 <div>
97 <input name="password" type="password" value={values.password} onChange={handleChange} onBlur={handleBlur} />
98 {touched.password && errors.password && <span className="error">{errors.password}</span>}
99 </div>
100 <button type="submit">Register</button>
101 </form>
102 );
103}
React Hook Form

React Hook Form (RHF) is the most performant form library for React. It uses uncontrolled components and ref-based validation, minimizing re-renders. It pairs well with Zod for schema-based validation.

react-hook-form.jsx
JSX
1import { useForm, FormProvider } from "react-hook-form";
2import { zodResolver } from "@hookform/resolvers/zod";
3import { z } from "zod";
4
5// Define schema with Zod
6const schema = z.object({
7 name: z.string().min(2, "Name must be at least 2 characters"),
8 email: z.string().email("Invalid email address"),
9 age: z.number().min(18, "Must be at least 18").max(120),
10 role: z.enum(["user", "admin", "moderator"]),
11 bio: z.string().max(500).optional(),
12 agreeToTerms: z.literal(true, {
13 errorMap: () => ({ message: "You must agree to terms" }),
14 }),
15});
16
17function RegistrationForm() {
18 const methods = useForm({
19 resolver: zodResolver(schema),
20 defaultValues: {
21 name: "",
22 email: "",
23 age: 18,
24 role: "user",
25 bio: "",
26 },
27 });
28
29 const { register, handleSubmit, formState: { errors, isSubmitting }, watch } = methods;
30
31 const onSubmit = async (data) => {
32 await fetch("/api/register", {
33 method: "POST",
34 headers: { "Content-Type": "application/json" },
35 body: JSON.stringify(data),
36 });
37 };
38
39 return (
40 <FormProvider {...methods}>
41 <form onSubmit={handleSubmit(onSubmit)}>
42 <div>
43 <label>Name</label>
44 <input {...register("name")} />
45 {errors.name && <span>{errors.name.message}</span>}
46 </div>
47 <div>
48 <label>Email</label>
49 <input {...register("email")} />
50 {errors.email && <span>{errors.email.message}</span>}
51 </div>
52 <div>
53 <label>Age</label>
54 <input type="number" {...register("age", { valueAsNumber: true })} />
55 {errors.age && <span>{errors.age.message}</span>}
56 </div>
57 <div>
58 <label>Role</label>
59 <select {...register("role")}>
60 <option value="user">User</option>
61 <option value="admin">Admin</option>
62 <option value="moderator">Moderator</option>
63 </select>
64 </div>
65 <div>
66 <label>Bio</label>
67 <textarea {...register("bio")} rows={4} />
68 {errors.bio && <span>{errors.bio.message}</span>}
69 </div>
70 <div>
71 <label>
72 <input type="checkbox" {...register("agreeToTerms")} />
73 I agree to the terms
74 </label>
75 {errors.agreeToTerms && <span>{errors.agreeToTerms.message}</span>}
76 </div>
77 <button type="submit" disabled={isSubmitting}>
78 {isSubmitting ? "Submitting..." : "Register"}
79 </button>
80 </form>
81 </FormProvider>
82 );
83}
🔥

pro tip

React Hook Form re-renders only the fields that change, not the entire form. For forms with many fields, this makes RHF dramatically faster than controlled-component approaches. The zodResolver integration gives you type-safe validation with excellent error messages.
Multi-Step Forms

Multi-step forms (wizards) break long forms into manageable steps. Each step validates before advancing, and the final step submits all data. They improve completion rates by reducing cognitive load.

multi-step-form.jsx
JSX
1function MultiStepForm() {
2 const [step, setStep] = useState(0);
3 const [data, setData] = useState({
4 personal: { name: "", email: "" },
5 address: { street: "", city: "", zip: "" },
6 payment: { card: "", expiry: "" },
7 });
8
9 const steps = [
10 {
11 title: "Personal Info",
12 component: PersonalStep,
13 validate: (d) => d.personal.name && d.personal.email,
14 },
15 {
16 title: "Address",
17 component: AddressStep,
18 validate: (d) => d.address.street && d.address.city,
19 },
20 {
21 title: "Payment",
22 component: PaymentStep,
23 validate: (d) => d.payment.card && d.payment.expiry,
24 },
25 ];
26
27 const updateData = (section, values) => {
28 setData(prev => ({ ...prev, [section]: { ...prev[section], ...values } }));
29 };
30
31 const canProceed = steps[step].validate(data);
32
33 const handleSubmit = async () => {
34 await fetch("/api/checkout", {
35 method: "POST",
36 body: JSON.stringify(data),
37 });
38 };
39
40 const CurrentStep = steps[step].component;
41
42 return (
43 <div>
44 {/* Progress indicator */}
45 <div className="flex gap-2 mb-6">
46 {steps.map((s, i) => (
47 <div key={i} className={`step ${i === step ? "active" : ""} ${i < step ? "completed" : ""}`}>
48 <span className="step-number">{i + 1}</span>
49 <span>{s.title}</span>
50 </div>
51 ))}
52 </div>
53
54 {/* Current step */}
55 <CurrentStep data={data} onUpdate={updateData} />
56
57 {/* Navigation */}
58 <div className="flex gap-2 mt-6">
59 {step > 0 && (
60 <button onClick={() => setStep(s => s - 1)}>Previous</button>
61 )}
62 {step < steps.length - 1 ? (
63 <button onClick={() => setStep(s => s + 1)} disabled={!canProceed}>
64 Next
65 </button>
66 ) : (
67 <button onClick={handleSubmit} disabled={!canProceed}>
68 Submit Order
69 </button>
70 )}
71 </div>
72 </div>
73 );
74}
File Upload

File uploads require handling FormData, previewing files, showing upload progress, and validating file types/sizes. Here's a production-ready pattern:

file-upload.jsx
JSX
1function FileUpload({ accept, onUpload, maxSize = 5 * 1024 * 1024 }) {
2 const [preview, setPreview] = useState(null);
3 const [uploading, setUploading] = useState(false);
4 const [progress, setProgress] = useState(0);
5 const [error, setError] = useState(null);
6 const fileRef = useRef(null);
7
8 const handleFileChange = (e) => {
9 const file = e.target.files[0];
10 if (!file) return;
11
12 setError(null);
13
14 if (file.size > maxSize) {
15 setError(`File too large. Max size: ${(maxSize / 1024 / 1024).toFixed(1)}MB`);
16 return;
17 }
18
19 if (accept && !accept.split(",").some(type => {
20 if (type.startsWith(".")) return file.name.endsWith(type);
21 return file.type.match(type.replace("*", ".*"));
22 })) {
23 setError(`Invalid file type. Accepted: ${accept}`);
24 return;
25 }
26
27 // Preview images
28 if (file.type.startsWith("image/")) {
29 const reader = new FileReader();
30 reader.onload = (e) => setPreview(e.target.result);
31 reader.readAsDataURL(file);
32 }
33 };
34
35 const handleUpload = async () => {
36 const file = fileRef.current?.files?.[0];
37 if (!file) return;
38
39 const formData = new FormData();
40 formData.append("file", file);
41
42 setUploading(true);
43 setProgress(0);
44
45 try {
46 const xhr = new XMLHttpRequest();
47 xhr.upload.onprogress = (e) => {
48 if (e.lengthComputable) {
49 setProgress(Math.round((e.loaded / e.total) * 100));
50 }
51 };
52
53 const response = await new Promise((resolve, reject) => {
54 xhr.onload = () => resolve(JSON.parse(xhr.responseText));
55 xhr.onerror = () => reject(new Error("Upload failed"));
56 xhr.open("POST", "/api/upload");
57 xhr.send(formData);
58 });
59
60 onUpload(response.url);
61 } catch (err) {
62 setError(err.message);
63 } finally {
64 setUploading(false);
65 }
66 };
67
68 return (
69 <div>
70 <input ref={fileRef} type="file" accept={accept} onChange={handleFileChange} />
71 {preview && <img src={preview} alt="Preview" className="mt-2 max-w-xs" />}
72 {uploading && (
73 <div className="mt-2">
74 <div className="progress-bar" style={{ width: `${progress}%` }} />
75 <span>{progress}%</span>
76 </div>
77 )}
78 {error && <p className="text-red-500">{error}</p>}
79 <button onClick={handleUpload} disabled={uploading || !fileRef.current?.files?.[0]}>
80 {uploading ? "Uploading..." : "Upload"}
81 </button>
82 </div>
83 );
84}

warning

Always validate file types and sizes on both client and server. Client-side validation improves UX but is bypassable. Never trust client-side validation alone — always validate on the server before processing or storing files.
Form Accessibility

Accessible forms ensure all users can interact with your forms, including those using screen readers and keyboard navigation. Every input needs a label, errors need to be announced, and focus management must be handled correctly.

accessible-form.jsx
JSX
1function AccessibleForm() {
2 const [errors, setErrors] = useState({});
3
4 return (
5 <form onSubmit={handleSubmit} noValidate>
6 {/* Always associate label with input via htmlFor/id */}
7 <div>
8 <label htmlFor="full-name">
9 Full Name <span aria-hidden="true">*</span>
10 </label>
11 <input
12 id="full-name"
13 name="name"
14 type="text"
15 required
16 aria-required="true"
17 aria-invalid={!!errors.name}
18 aria-describedby={errors.name ? "name-error" : undefined}
19 />
20 {errors.name && (
21 <span id="name-error" role="alert" className="error">
22 {errors.name}
23 </span>
24 )}
25 </div>
26
27 {/* Error summary for screen readers */}
28 {Object.keys(errors).length > 0 && (
29 <div role="alert" aria-live="assertive">
30 <h3>Please fix {Object.keys(errors).length} error(s):</h3>
31 <ul>
32 {Object.entries(errors).map(([field, message]) => (
33 <li key={field}>
34 <a href={`#${field}`}>{message}</a>
35 </li>
36 ))}
37 </ul>
38 </div>
39 )}
40
41 {/* Group related fields with fieldset */}
42 <fieldset>
43 <legend>Notification Preferences</legend>
44 <label>
45 <input type="checkbox" name="emailNotif" /> Email notifications
46 </label>
47 <label>
48 <input type="checkbox" name="smsNotif" /> SMS notifications
49 </label>
50 </fieldset>
51
52 <button type="submit">Save Preferences</button>
53 </form>
54 );
55}

best practice

Every form input must have an associated <label>. Use htmlFor/id pairing. Error messages should use aria-describedby to link them to inputs. Use role="alert" for dynamic error announcements.