Forms: Controlled vs. Uncontrolled

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.

Controlled Components (The React Way)

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.

How it works

  1. State Driver: We create a state variable (useState) to hold the input's value.
  2. Locked Value: We pass this state to the input's value prop. This locks the input. If we try to type, nothing happens because the state hasn't changed yet.
  3. The Loop: We add an 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>
  );
}

The Trade-off

  • 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.


Uncontrolled Components (The DOM Way)

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.

How it works

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>
  );
}

The Trade-off

  • 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.


Complex Forms & Manual Validation

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.

Handling Multiple Inputs (The Object Pattern)

Instead of individual variables, we initialize one object to hold all fields.

const [formData, setFormData] = useState({
  firstName: "",
  lastName: "",
  email: "",
  role: "user",
});

The Generic Change Handler

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:

  1. The name attribute: We give every input a name that matches the state key (e.g., name="email").
  2. Computed Property Names: We use [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
  }));
};

The Spread Operator ...prev

Why 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.

The Validation Nightmare (Manual Logic)

This is where Raw React starts to become painful. To validate a form manually, we need:

  1. State for Errors (errors object).
  2. State for Submission Status (isSubmitting).
  3. A validation function that checks every field.
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>
  );
}

The Pain Points of Manual Forms

Looking at the code above, we can identify several scaling issues:

  1. Boilerplate: We wrote 40 lines of code for a simple 2-field form. Imagine a 20-field form!
  2. Re-renders: Every letter typed in email re-renders the password field too (because they share the same parent state object).
  3. Fragile Validation: Complex rules (like "Password must contain 1 number and match the Confirm Password field") create spaghetti code full of if/else blocks.
  4. Touched States: We usually want to show errors only after the user leaves the field (onBlur). Implementing touched state adds even more complexity.

Professional Forms: RHF & Zod

Manual form handling leads to re-render issues and spaghetti code validation. To solve this, the React industry has standardized on two libraries:

  1. React Hook Form (RHF): Handles the data gathering and performance.
  2. Zod: Handles the validation logic (Schema).

React Hook Form

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.

The useForm Hook

We 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.

Zod

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.

Creating a Schema

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"),
});

The Integration: zodResolver

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>
  );
}

Why this wins

FeatureManual React StateRHF + Zod
Code Size50+ lines of handlers & logic15 lines of declarative code
PerformanceRe-renders on every letterZero re-renders while typing
ValidationComplex if/else spaghettiClean Schema definition
MaintenanceHard to add new fieldsJust add one line to schema & JSX

📝 Summary Table

FeatureControlled ComponentUncontrolled Component
Data SourceReact State (useState)DOM (ref)
Prop Usedvalue={state}defaultValue="initial"
UpdatesReal-time (on every keystroke)On Demand (on submit)
PerformanceSlower (Re-renders often)Faster (No re-renders while typing)
Best ForValidationLarge forms
ConceptExplanationWhy we use it
Object StateuseState({ a: 1, b: 2 })Keeps related form data together.
Computed Property[e.target.name]Allows one handler to update dynamic keys.
Spread Operator...prevPreserves existing data during updates.
e.preventDefaultEvent methodStops the browser from refreshing the page on submit.
Validation Stateconst [errors, setErrors]Manually tracking which fields are invalid.

🛑 Stop and Think

1. Why do we use 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."

2. Is it bad to use Controlled Components?

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.

3. Why did we destructure 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.

4. What happens if I forget 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.

5. How does {...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.