Forms in React

1. 📖 Introduction

Forms are the backbone of nearly every interactive web application — login screens, checkout flows, search bars, and settings pages all rely on them. In React, forms behave a little differently than in plain HTML because React encourages you to keep the UI in sync with your application state. This tutorial walks through everything from the basics of <input> elements to advanced validation, accessibility, performance, and popular form libraries like React Hook Form and Formik.

Information

By the end of this guide, you'll be able to build robust, accessible, and performant forms in React — from a simple text field to a fully validated, dynamic, multi-input form.

2. ❓ What are Forms?

A form is a collection of interactive elements — input, textarea, select, button, and more — that allow users to submit data to an application. In traditional HTML, forms submit data to a server and trigger a full page reload. In React, we usually intercept that submission and handle it entirely with JavaScript, giving us full control over validation, feedback, and data flow.

  • Collecting user input (text, numbers, files, etc.)
  • Validating that input before it's used
  • Submitting data to an API or parent component
  • Providing feedback like errors, loading, and success states

3. 🧱 React Form Basics

Unlike plain HTML, where form elements manage their own internal state, React typically manages form data through state. This lets you read, validate, and manipulate values at any time, rather than only when the form is submitted.

BasicForm.jsx

function BasicForm() {
  const [name, setName] = useState("");

  return (
    <form>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
    </form>
  );
}

Here, the <input>'s value is driven entirely by React state — a pattern known as a controlled component, which we'll explore next.

4. đŸŽ›ī¸ Controlled Components

A controlled component is a form element whose value is set and updated by React state, rather than by the DOM itself. Every keystroke triggers an onChange handler, which updates state, which then re-renders the input with the new value.

ControlledInput.jsx

function ControlledInput() {
  const [email, setEmail] = useState("");

  const handleChange = (e) => setEmail(e.target.value);

  return (
    <input
      type="email"
      value={email}
      onChange={handleChange}
      placeholder="you@example.com"
    />
  );
}

Best Practice

Controlled components are the recommended default in React because they make form state predictable, testable, and easy to validate in real time.

5. đŸ•šī¸ Uncontrolled Components

An uncontrolled component lets the DOM manage its own state internally. Instead of tracking every change with onChange, you read the value only when you need it — typically using a ref.

UncontrolledInput.jsx

function UncontrolledInput() {
  const inputRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    alert(inputRef.current.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" ref={inputRef} defaultValue="Hello" />
      <button type="submit">Submit</button>
    </form>
  );
}

Notice the use of defaultValue instead of value — this tells React "set this once, then leave it alone."

6. âš–ī¸ Controlled vs Uncontrolled Components

Choosing between the two approaches depends on your form's complexity, performance needs, and how much control you want over each keystroke.

AspectControlledUncontrolled
Source of truthReact stateDOM
Real-time validationEasyHarder
Performance on large formsCan re-render oftenFewer re-renders
Integration with 3rd-party DOM libsTrickierEasier
Typical use caseMost formsFile inputs, simple one-off forms

Tip

A common pattern is a hybrid approach: use uncontrolled inputs for simple fields and controlled inputs where you need real-time feedback, such as password strength meters.

7. đŸ—‚ī¸ Form State Management

As forms grow beyond a couple of fields, tracking each one with a separate useState call becomes unwieldy. A common solution is to consolidate all field values into a single state object.

FormState.jsx

function SignupForm() {
  const [formData, setFormData] = useState({
    username: "",
    email: "",
    password: "",
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  };

  return (
    <form>
      <input name="username" value={formData.username} onChange={handleChange} />
      <input name="email" value={formData.email} onChange={handleChange} />
      <input name="password" type="password" value={formData.password} onChange={handleChange} />
    </form>
  );
}

Note

Using the name attribute alongside a generic handleChange function avoids writing a separate handler per field.

8. âŒ¨ī¸ Handling User Input

Every form input generates an onChange event containing an event.target object. This object exposes the field's value (and, for checkboxes, its checked state) so you can update your state accordingly.

User types/selects
Browser fires onChange
Handler reads event.target
State updates
Component re-renders with new value
Extract value or checked

9. 🔤 Text Input

The most common form element is the plain text <input>. It supports many type values including text, email, password, number, tel, and url.

TextInput.jsx

<input
  type="text"
  name="fullName"
  value={fullName}
  onChange={(e) => setFullName(e.target.value)}
  placeholder="Full name"
/>

10. 📝 Textarea

Unlike plain HTML, where <textarea> content sits between the tags, React's <textarea> uses a value attribute — just like <input>.

Textarea.jsx

<textarea
  name="bio"
  value={bio}
  onChange={(e) => setBio(e.target.value)}
  rows={4}
/>

11. đŸ”Ŋ Select Dropdown

React's <select> also uses value on the parent element rather than an selected attribute on individual <option> elements.

SelectDropdown.jsx

<select value={country} onChange={(e) => setCountry(e.target.value)}>
  <option value="">Select a country</option>
  <option value="us">United States</option>
  <option value="in">India</option>
  <option value="de">Germany</option>
</select>

12. â˜‘ī¸ Checkbox

Checkboxes are boolean inputs, so they use checked instead of value.

Checkbox.jsx

<label>
  <input
    type="checkbox"
    checked={agreed}
    onChange={(e) => setAgreed(e.target.checked)}
  />
  I agree to the <b>terms and conditions</b>
</label>

13. 🔘 Radio Buttons

Radio buttons share a common name so only one option in the group can be selected at a time. In React, you compare each radio's value against the current state to determine checked.

RadioButtons.jsx

function PlanPicker() {
  const [plan, setPlan] = useState("basic");

  return (
    <>
      {["basic", "pro", "enterprise"].map((option) => (
        <label key={option}>
          <input
            type="radio"
            name="plan"
            value={option}
            checked={plan === option}
            onChange={(e) => setPlan(e.target.value)}
          />
          {option}
        </label>
      ))}
    </>
  );
}

14. 📎 File Input

File inputs are always uncontrolled in React — you cannot programmatically set a file's value for security reasons. Instead, read the selected files from event.target.files.

FileInput.jsx

function AvatarUpload() {
  const [file, setFile] = useState(null);

  const handleFileChange = (e) => {
    setFile(e.target.files[0]);
  };

  return (
    <input type="file" accept="image/*" onChange={handleFileChange} />
  );
}

Warning

Because file inputs cannot be set programmatically, calling setFile(null) will not visually clear the input's displayed filename — you must reset the underlying DOM element via a ref and its key.

15. 🧩 Multiple Inputs

When a form has many fields, a shared handleChange function keyed off event.target.name keeps your code DRY.

MultipleInputs.jsx

function ProfileForm() {
  const [values, setValues] = useState({ firstName: "", lastName: "", age: "" });

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setValues((prev) => ({
      ...prev,
      [name]: type === "checkbox" ? checked : value,
    }));
  };

  return (
    <form>
      <input name="firstName" value={values.firstName} onChange={handleChange} />
      <input name="lastName" value={values.lastName} onChange={handleChange} />
      <input name="age" type="number" value={values.age} onChange={handleChange} />
    </form>
  );
}

16. ➕ Dynamic Forms

Sometimes a form needs a variable number of fields — for example, adding multiple phone numbers or line items to an invoice. Store these as an array in state, and render one input per array item.

DynamicForm.jsx

function PhoneList() {
  const [phones, setPhones] = useState([""]);

  const updatePhone = (index, value) => {
    const updated = [...phones];
    updated[index] = value;
    setPhones(updated);
  };

  const addPhone = () => setPhones([...phones, ""]);
  const removePhone = (index) => setPhones(phones.filter((_, i) => i !== index));

  return (
    <>
      {phones.map((phone, i) => (
        <div key={i}>
          <input value={phone} onChange={(e) => updatePhone(i, e.target.value)} />
          <button type="button" onClick={() => removePhone(i)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={addPhone}>Add phone</button>
    </>
  );
}

Important

Always give each dynamic field a stable key — ideally a unique id rather than the array index — to avoid subtle UI bugs when items are removed or reordered.

17. 📤 Form Submission

The <form> element's onSubmit handler is where you typically validate data and send it onward — to an API, a parent component, or a state manager.

SubmitForm.jsx

function ContactForm() {
  const [message, setMessage] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log("Submitting:", message);
  };

  return (
    <form onSubmit={handleSubmit}>
      <textarea value={message} onChange={(e) => setMessage(e.target.value)} />
      <button type="submit">Send</button>
    </form>
  );
}

18. 🛑 Preventing Default Submission

By default, submitting an HTML form triggers a full page reload and navigates to the form's action URL. Calling event.preventDefault() inside your onSubmit handler stops this, letting React handle the submission entirely in JS.

Caution

Forgetting event.preventDefault() is one of the most common beginner mistakes — it causes the page to refresh and all React state to be lost.

19. đŸ“Ĩ Reading Form Values

There are two main strategies for reading form values at submission time.

FromState.jsx

const handleSubmit = (e) => {
  e.preventDefault();
  console.log(formData); // already in state
};

FromFormData.jsx

const handleSubmit = (e) => {
  e.preventDefault();
  const data = new FormData(e.target);
  console.log(Object.fromEntries(data));
};

The FormData approach works well for uncontrolled forms since it reads directly from the DOM, without needing state at all.

20. 🔄 Resetting Forms

For controlled forms, reset by setting state back to its initial values. For uncontrolled forms, you can call the native form.reset() method or change the input's key to force React to remount it.

ResetForm.jsx

const initialState = { name: "", email: "" };
const [formData, setFormData] = useState(initialState);

const handleReset = () => setFormData(initialState);

21. ✅ Form Validation

Validation ensures the data a user submits is correct, complete, and safe to process. React supports both the browser's built-in validation and fully custom, JS-driven validation.

22. 🌐 Native HTML Validation

HTML attributes like required, minLength, maxLength, pattern, and type="email" trigger the browser's built-in validation UI automatically, with no extra code.

NativeValidation.jsx

<input type="email" required minLength={5} placeholder="Email" />

Tip

Native validation is fast to set up but offers limited styling control and inconsistent messages across browsers.

23. đŸ› ī¸ Custom Validation

Custom validation gives you full control over rules, timing, and error messages by writing your own validation functions in JS.

CustomValidation.jsx

function validate(values) {
  const errors = {};
  if (!values.email.includes("@")) errors.email = "Enter a valid email";
  if (values.password.length < 8) errors.password = "Minimum 8 characters";
  return errors;
}

24. 🚨 Error Handling

Store validation errors in their own piece of state, then conditionally render error messages next to each field.

ErrorHandling.jsx

const [errors, setErrors] = useState({});

const handleSubmit = (e) => {
  e.preventDefault();
  const validationErrors = validate(formData);
  setErrors(validationErrors);
  if (Object.keys(validationErrors).length === 0) {
    // proceed with submission
  }
};

25. âąī¸ Real-Time Validation

Running validation inside onChange or onBlur gives users immediate feedback as they type or move between fields, rather than waiting until submission.

RealTimeValidation.jsx

const handleBlur = (e) => {
  const { name, value } = e.target;
  const fieldErrors = validate({ ...formData, [name]: value });
  setErrors((prev) => ({ ...prev, [name]: fieldErrors[name] }));
};

Best Practice

Validating on onBlur rather than every onChange keystroke usually feels less naggy to users while still catching mistakes early.

26. âŗ Async Validation

Some checks — like confirming a username is unique — require a server round-trip. Debounce these calls so you're not hitting the server on every keystroke.

AsyncValidation.jsx

useEffect(() => {
  if (!username) return;
  const timeout = setTimeout(async () => {
    const res = await fetch(`/api/check-username?u=${username}`);
    const { available } = await res.json();
    setErrors((prev) => ({ ...prev, username: available ? "" : "Username taken" }));
  }, 500);
  return () => clearTimeout(timeout);
}, [username]);

27. đŸšĢ Disabled States

Disable the submit button while a form is invalid or mid-submission to prevent duplicate or premature submissions.

DisabledState.jsx

<button type="submit" disabled={isSubmitting || Object.keys(errors).length > 0}>
  Submit
</button>

28. âŗ Loading States

Track an isSubmitting flag to show spinners, disable inputs, and prevent duplicate network requests while an async submission is in flight.

LoadingState.jsx

const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async (e) => {
  e.preventDefault();
  setIsSubmitting(true);
  try {
    await submitForm(formData);
  } finally {
    setIsSubmitting(false);
  }
};

29. â™ŋ Form Accessibility

Accessible forms ensure every user — including those using screen readers or keyboard-only navigation — can understand and complete them.

  • Always pair inputs with a <label> using htmlFor/id
  • Use aria-invalid and aria-describedby to associate error messages with fields
  • Ensure focus order follows a logical, visual sequence
  • Announce errors with role="alert" so screen readers pick them up automatically

Important

Accessibility isn't optional polish — for many users, it determines whether they can complete your form at all.

30. ⚡ Form Performance

Large forms with dozens of controlled inputs can suffer from unnecessary re-renders. A few techniques help keep things fast:

  1. Split large forms into smaller, memoized subcomponents
  2. Use uncontrolled inputs with refs for fields that don't need real-time validation
  3. Debounce expensive validation or API calls
  4. Consider a form library (see Section 31) that avoids re-rendering the whole tree per keystroke

31. đŸ“Ļ Form Libraries Overview

As forms grow in complexity — nested fields, arrays, cross-field validation, async checks — hand-rolled state management becomes harder to maintain. Dedicated libraries solve these problems out of the box.

LibraryApproachBest for
React Hook FormUncontrolled + refsPerformance-sensitive, large forms
FormikControlled + render props/hooksFamiliar, batteries-included API

32. đŸĒ React Hook Form

React Hook Form uses uncontrolled inputs under the hood via refs, meaning most fields don't cause re-renders on every keystroke — making it very fast for large forms.

ReactHookForm.jsx

import { useForm } from "react-hook-form";

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email", { required: true })} />
      {errors.email && <span>Email is required</span>}
      <button type="submit">Login</button>
    </form>
  );
}

Learn more in the official React Hook Form docs.

33. đŸ§Ē Formik

Formik takes a controlled, hooks-and-components approach, bundling state management, validation (often paired with Yup), and submission handling into one cohesive API.

FormikExample.jsx

import { Formik, Form, Field, ErrorMessage } from "formik";

function SignupForm() {
  return (
    <Formik
      initialValues={{ email: "" }}
      validate={(values) => {
        const errors = {};
        if (!values.email) errors.email = "Required";
        return errors;
      }}
      onSubmit={(values) => console.log(values)}
    >
      <Form>
        <Field name="email" type="email" />
        <ErrorMessage name="email" component="div" />
        <button type="submit">Sign up</button>
      </Form>
    </Formik>
  );
}

See the Formik documentation for the full API.

34. 🔷 TypeScript with Forms

Typing form state clarifies exactly what shape your data takes and catches typos in field names at compile time rather than at runtime.

TypedForm.tsx

interface SignupValues {
  username: string;
  email: string;
  age: number;
}

function SignupForm() {
  const [values, setValues] = useState<SignupValues>({
    username: "",
    email: "",
    age: 0,
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setValues((prev) => ({ ...prev, [name]: value }));
  };

  return <form>{/* fields here */}</form>;
}

35. 🏆 Best Practices

  1. Prefer controlled components unless you have a specific performance reason not to
  2. Consolidate related fields into a single state object rather than many useState calls
  3. Always call event.preventDefault() in your submit handler
  4. Validate both on the client and the server — never trust client-side validation alone
  5. Give every input a matching <label> for accessibility
  6. Use a form library once your form exceeds a handful of fields or needs complex validation

36. âš ī¸ Common Mistakes

  • Forgetting event.preventDefault(), causing an unwanted page reload
  • Mixing controlled and uncontrolled patterns on the same input (e.g. providing both value and no onChange)
  • Using array indices as key in dynamic forms, causing state to "stick" to the wrong row
  • Not resetting error state after a successful submission
  • Skipping labels and relying only on placeholder text for accessibility

Danger

An input with a value prop but no onChange handler becomes read-only and React will warn you in the console — this is a very common source of "my input won't let me type" bugs.

37. đŸ’Ŧ Frequently Asked Questions

Why does React warn about a "controlled input changing to uncontrolled"?

This happens when an input's value switches from a defined value (like an empty string) to undefined or null — often because a state field was never initialized. Always initialize controlled fields to an empty string, not undefined.

Should I validate on every keystroke?

Generally, no — validating on onBlur or on submit feels friendlier. Real-time, per-keystroke validation is best reserved for things like password strength meters where instant feedback adds real value.

Do I need a form library for a simple contact form?

Usually not. Plain useState and a couple of handlers are enough for small forms. Reach for a library once you're managing complex validation, nested fields, or many interdependent inputs.

38. 📌 Summary

>>The best forms are the ones users barely notice — they simply work.