Handling forms is one of the most common tasks in web development, but React handles them differently than standard HTML. In React, we have two distinct patterns for managing input data: Controlled Components and Uncontrolled Components.
In a standard HTML form, the <input> element maintains its own internal state. When we type, the DOM updates itself.
In a Controlled Component, React takes over. The React State becomes the Single Source of Truth. The DOM is no longer allowed to change itself; it must ask React for the new value.
useState) to hold the input's value.value prop. This locks the input. If we try to type, nothing happens because the state hasn't changed yet.onChange handler. When the user types, we update the state. The re-render updates the input's value.import { useState } from "react";
function ControlledInput() {
const [text, setText] = useState("");
const handleChange = (e) => {
// 1. User types
// 2. We take the new value and update React State
setText(e.target.value);
};
return (
<div>
{/* 3. React passes the NEW state back to the input */}
<input type="text" value={text} onChange={handleChange} />
<p>Real-time Mirror: {text}</p>
</div>
);
}Pros:
Instant Validation: We can validate data while the user types (e.g., password strength meter).
Conditional Logic: Easy to disable a Submit button if the input is empty.
Cons:
Re-renders: Every single keystroke triggers a component re-render. In a massive form with 50 inputs, typing in one field might cause the entire form to re-render 50 times significantly slowing down the app.
Sometimes, we don't need React to micro-manage every keystroke. We just want the value when the user clicks Submit. This is the Uncontrolled pattern.
Here, the DOM maintains the state (just like standard HTML). React ignores the input until we explicitly ask for the value.
We use the useRef hook to create a reference to the DOM node.
import { useRef } from "react";
function UncontrolledInput() {
const nameRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
// We "pull" the value directly from the DOM only when needed
alert(`Submitted Name: ${nameRef.current.value}`);
};
return (
<form onSubmit={handleSubmit}>
<label>Name: </label>
{/* defaultValue sets the initial text, but allows editing */}
<input type="text" ref={nameRef} defaultValue="Guest" />
<button type="submit">Submit</button>
</form>
);
}Pros:
Performance: Typing does NOT trigger re-renders. This is crucial for performance-critical apps or very large forms.
Integration: Easier to use with non-React libraries (like Google Maps autocomplete) that expect to control the DOM.
Cons:
No Instant Feedback: We can't show a validation error while the user is typing.
Real apps have forms with 10, 20, or 50 fields. Creating 50 separate useState variables (name, setName, email, setEmail...) is unmaintainable.
To solve this, we group our form data into a single State Object.
Instead of individual variables, we initialize one object to hold all fields.
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
role: "user",
});We don't want to write a separate handleEmailChange, handleNameChange, etc. We write one function that can handle any input.
To do this, we use two JavaScript features:
name attribute: We give every input a name that matches the state key (e.g., name="email").[e.target.name] to dynamically target the key.const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev, // 1. Copy the old state (CRITICAL!)
[name]: value, // 2. Update ONLY the field that changed
}));
};...prevWhy is ...prev necessary?
useState updates are replacements, not merges.
If we write setFormData({ email: "new@mail.com" }), React replaces the entire object with just that one field. firstName and lastName would disappear.
The spread operator ...prev says: Copy everything currently in the object, and then let me overwrite specific keys.
This is where Raw React starts to become painful. To validate a form manually, we need:
errors object).isSubmitting).function SignupForm() {
const [formData, setFormData] = useState({ email: "", password: "" });
const [errors, setErrors] = useState({}); // Stores validation messages
const [isSubmitting, setIsSubmitting] = useState(false);
const validate = () => {
const newErrors = {};
// Manual IF statements for every rule
if (!formData.email.includes("@")) {
newErrors.email = "Invalid email address";
}
if (formData.password.length < 6) {
newErrors.password = "Password must be at least 6 chars";
}
setErrors(newErrors);
// Return true if no errors exist
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault(); // 1. Stop browser refresh
if (!validate()) return; // 2. Stop if invalid
setIsSubmitting(true);
await submitToAPI(formData); // 3. Send Data
setIsSubmitting(false);
};
return (
<form onSubmit={handleSubmit}>
<div>
<input name="email" value={formData.email} onChange={handleChange} />
{/* Conditional Rendering for Error Message */}
{errors.email && <span className="error">{errors.email}</span>}
</div>
<button disabled={isSubmitting}>
{isSubmitting ? "Loading..." : "Sign Up"}
</button>
</form>
);
}Looking at the code above, we can identify several scaling issues:
email re-renders the password field too (because they share the same parent state object).if/else blocks.touched state adds even more complexity.Manual form handling leads to re-render issues and spaghetti code validation. To solve this, the React industry has standardized on two libraries:
RHF fixes the performance problem by using Uncontrolled Components under the hood. instead of re-rendering our component on every keystroke, it registers our inputs ref and manages the state internally.
useForm HookWe stop creating useState for every field. We just ask RHF for the tools we need.
import { useForm } from "react-hook-form";
function ProfessionalForm() {
// Destructure the tools we need
const {
register, // Connects input to RHF
handleSubmit, // Wraps our submit function
formState: { errors, isSubmitting }, // Auto-tracked state
} = useForm();
const onSubmit = (data) => {
// 'data' is automatically collected into a nice object
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* The "register" function returns the necessary props
(onChange, onBlur, ref, name) automatically.
*/}
<input {...register("email")} placeholder="Email" />
<input {...register("password")} type="password" />
<button disabled={isSubmitting}>Submit</button>
</form>
);
}Key Difference: This component does NOT re-render when we type. RHF listens silently in the background.
RHF gathers the data, but it doesn't know what the data should look like. That is Zod's job. Zod is a schema declaration library. We define the rules outside our component, keeping our UI clean.
import { z } from "zod";
const signUpSchema = z.object({
email: z.string().email("Invalid email format"),
password: z.string().min(6, "Password must be 6+ chars"),
age: z.coerce.number().min(18, "You must be 18 or older"),
});We connect the two worlds using a Resolver. This tells RHF: "Before you submit, ask Zod if this data is valid."
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
function ValidatedForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(signUpSchema), // <--- The Connection
});
const onSubmit = (data) => console.log("Valid Data:", data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Email</label>
<input {...register("email")} />
{/* RHF automatically maps Zod error messages here */}
{errors.email && <p className="text-red-500">{errors.email.message}</p>}
</div>
<div>
<label>Age</label>
<input type="number" {...register("age")} />
{errors.age && <p className="text-red-500">{errors.age.message}</p>}
</div>
<button>Submit</button>
</form>
);
}| Feature | Manual React State | RHF + Zod |
|---|---|---|
| Code Size | 50+ lines of handlers & logic | 15 lines of declarative code |
| Performance | Re-renders on every letter | Zero re-renders while typing |
| Validation | Complex if/else spaghetti | Clean Schema definition |
| Maintenance | Hard to add new fields | Just add one line to schema & JSX |
| Feature | Controlled Component | Uncontrolled Component |
|---|---|---|
| Data Source | React State (useState) | DOM (ref) |
| Prop Used | value={state} | defaultValue="initial" |
| Updates | Real-time (on every keystroke) | On Demand (on submit) |
| Performance | Slower (Re-renders often) | Faster (No re-renders while typing) |
| Best For | Validation | Large forms |
| Concept | Explanation | Why we use it |
|---|---|---|
| Object State | useState({ a: 1, b: 2 }) | Keeps related form data together. |
| Computed Property | [e.target.name] | Allows one handler to update dynamic keys. |
| Spread Operator | ...prev | Preserves existing data during updates. |
e.preventDefault | Event method | Stops the browser from refreshing the page on submit. |
| Validation State | const [errors, setErrors] | Manually tracking which fields are invalid. |
defaultValue instead of value in Uncontrolled components?If we pass a value prop to an input without an onChange handler, React assumes we want a Read-Only field. It will lock the input and prevent the user from typing.
defaultValue tells React: "Start with this text, but let the DOM handle future updates."
No! Controlled components are the default standard in React because they are predictable. We should only switch to Uncontrolled components (or libraries that use them) if we notice performance issues or need to simplify a massive form.
const { name, value } = e.target before setFormData?Because React state updates can be asynchronous. If we access e.target inside the callback setFormData(prev => ...e.target.value), React might have already recycled the event object (Event Pooling), leading to null errors. It is best practice to pull the values out first.
value={formData.email} in the input?We create an Uncontrolled Component by accident. The input will work, but our React state (formData) might not match what is on the screen, leading to bugs when we validate. This is a common warning: Component changed from uncontrolled to controlled.
{...register("email")} work?The spread operator ... takes the object returned by register and spreads its properties onto the input.
register("email") returns an object looking roughly like:
{
onChange: (e) => ...,
onBlur: (e) => ...,
name: "email",
ref: ...
}
This automatically wires up the input to RHF's internal tracking system.