React Forms
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.
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.
| 1 | function 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
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.
| 1 | import { useRef } from "react"; |
| 2 | |
| 3 | function 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 |
| 28 | function 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
Validation ensures users submit correct data. There are three types: HTML5 native validation, client-side validation (real-time feedback), and server-side validation (authoritative).
| 1 | // Validation with custom hooks |
| 2 | function 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 |
| 46 | function 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 |
| 77 | function 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 (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.
| 1 | import { useForm, FormProvider } from "react-hook-form"; |
| 2 | import { zodResolver } from "@hookform/resolvers/zod"; |
| 3 | import { z } from "zod"; |
| 4 | |
| 5 | // Define schema with Zod |
| 6 | const 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 | |
| 17 | function 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
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.
| 1 | function 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 uploads require handling FormData, previewing files, showing upload progress, and validating file types/sizes. Here's a production-ready pattern:
| 1 | function 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
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.
| 1 | function 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