React

Forms & Controlled Components

15 Questions

In controlled components, React is the single source of truth — the form element's value is always driven by state via the value prop, and every user input is captured through onChange to update that state. In uncontrolled components, the DOM manages the value itself and you read it on demand via a ref, similar to how vanilla HTML forms work. Controlled inputs give you full control — instant validation, conditional disabling, and dynamic values — while uncontrolled inputs are simpler for read-once scenarios like file uploads. For most modern forms, the controlled pattern is preferred because the form state is always predictable and testable.
// Controlled
const [value, setValue] = useState('');
<input value={value} onChange={e => setValue(e.target.value)} />

// Uncontrolled
const inputRef = useRef();
<input ref={inputRef} defaultValue="hello" />
// Read: inputRef.current.value
Controlled components are preferred because they keep the UI and state in sync, making validation and conditional logic straightforward.

Why it matters: With controlled components React always knows what's in the input, making validation, conditional rendering, and form resetting simple.

Real applications: Login forms, registration forms, filter forms — anywhere you need to validate or react to input before submission.

Common mistakes: Mixing controlled and uncontrolled — using both value and defaultValue on the same input causes a React warning.

Attach an onSubmit handler to the <form> element rather than using onClick on the submit button. Always call e.preventDefault() at the start of the handler to stop the browser's default behavior of refreshing the page. This approach captures all submission methods — clicking the button, pressing Enter in an input, or submitting programmatically — in a single handler. Validate your state values inside the handler before making any API calls.
function ContactForm() {
  const [email, setEmail] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Submitted:', email);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <button type="submit">Send</button>
    </form>
  );
}
Always prevent default submission to handle data in JavaScript rather than triggering a server round-trip.

Why it matters: Without preventing default, the browser reloads the page on every form submit, losing all React state.

Real applications: Login forms, checkout forms, search bars — all need to handle the submit event in JavaScript.

Common mistakes: Forgetting e.preventDefault() in the onSubmit handler, causing the page to unexpectedly reload.

React's onChange fires on every keystroke, calling your handler with a SyntheticEvent whose e.target.value contains the current text. This gives React its controlled input model: by calling setValue(e.target.value), state and the DOM input stay perfectly synchronized. Reading e.target.value inside the handler is important because the synthetic event is transient and may be recycled. For checkboxes, use e.target.checked instead of e.target.value.
function SearchBar() {
  const [query, setQuery] = useState('');

  return (
    <input
      type="text"
      placeholder="Search..."
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}
React's onChange behaves like the DOM's input event — it fires on every change, not just on blur like the native HTML change event.

Why it matters: Firing on every keystroke keeps React state in sync with what the user types, enabling real-time validation and character counts.

Real applications: Search boxes, password fields with strength meters, forms with a live preview panel.

Common mistakes: Using onBlur when you actually want onChange, or vice versa — they fire at different times and serve different purposes.

React normalizes <textarea> and <select> to use the same value + onChange pattern as text inputs, even though native HTML treats them differently. A native <textarea> gets text as child content, but in React you use the value prop. A native <select> uses the selected attribute on individual options, but in React you set value on the <select> itself. This consistent interface means all form elements are controlled the same way, making multi-field forms significantly simpler to manage.
const [bio, setBio] = useState('');
const [color, setColor] = useState('red');

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

<select value={color} onChange={e => setColor(e.target.value)}>
  <option value="red">Red</option>
  <option value="blue">Blue</option>
  <option value="green">Green</option>
</select>
Unlike HTML, React's textarea uses value instead of inner text, and select uses value instead of the selected attribute on options.

Why it matters: React normalizes these elements to work the same way as input — all controlled through a value prop and an onChange handler.

Real applications: Multi-line text areas in comment forms, dropdown selects for country or category pickers.

Common mistakes: Writing <textarea>Default text</textarea> instead of using the defaultValue or value prop.

For forms with many fields, using a single state object and one shared onChange handler is cleaner than separate state for every field. The key is to give each input a name attribute matching its key in the state object, then use e.target.name and e.target.value in the handler to update the correct field with a computed property name. This pattern scales to forms with 10+ fields without repetitive code. Use individual state variables for simple forms and switch to the object pattern when the form grows.
const [form, setForm] = useState({ name: '', email: '', age: '' });

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

<input name="name" value={form.name} onChange={handleChange} />
<input name="email" value={form.email} onChange={handleChange} />
<input name="age" value={form.age} onChange={handleChange} />
This pattern scales well — each input just needs a matching name prop that corresponds to a key in the state object.

Why it matters: One handler for all inputs reduces repetition and keeps the form code clean as the number of fields grows.

Real applications: Registration forms with many fields, settings pages, profile edit forms.

Common mistakes: Forgetting the name attribute on an input — the computed property key will be undefined and nothing will update.

Form validation is done by tracking error messages in a separate state object keyed by field name. Validate on onSubmit (all fields at once) or on onChange / onBlur (per field as the user interacts). Display error messages conditionally beneath each field and keep the submit button disabled when errors exist. For complex validation rules, schema libraries like Yup or Zod integrate cleanly with React Hook Form.
const [email, setEmail] = useState('');
const [error, setError] = useState('');

const handleSubmit = (e) => {
  e.preventDefault();
  if (!email.includes('@')) {
    setError('Please enter a valid email');
    return;
  }
  setError('');
  // proceed with submission
};

<form onSubmit={handleSubmit}>
  <input value={email} onChange={e => setEmail(e.target.value)} />
  {error && <span className="error">{error}</span>}
</form>
For complex forms, consider validation libraries like Yup or Zod paired with form libraries.

Why it matters: Validation prevents invalid data from being submitted and guides users to fix mistakes before they waste time.

Real applications: Email format checks, password strength rules, required field validation, matching password confirmation fields.

Common mistakes: Only validating on submit without showing inline errors — users don't know what went wrong until they click the button.

Uncontrolled inputs use a ref to access the DOM element directly, so the input manages its own value internally. You create the ref with useRef(), attach it with the ref prop, then read inputRef.current.value when needed — typically on submit. This avoids re-rendering on every keystroke, making it useful for simple read-once forms. However, you lose real-time validation, conditional disabling, and the ability to programmatically set the input value from external state.
import { useRef } from 'react';

function FileForm() {
  const nameRef = useRef();

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Name:', nameRef.current.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="Alice" />
      <button type="submit">Submit</button>
    </form>
  );
}
Uncontrolled components use defaultValue instead of value. They are useful for simple forms or integrating with non-React libraries.

Why it matters: Sometimes you don't need React to track every keystroke. Refs let you read the value only when you need it (like on submit).

Real applications: Simple search forms, file inputs, integrating with a third-party date picker or rich text editor.

Common mistakes: Using value without onChange on an uncontrolled input — React will warn you and the field becomes read-only.

File inputs are always uncontrolled — the browser's security model makes the value property of a file input read-only, so it cannot be set programmatically or controlled via React state. To access the selected files, either use a ref to read inputRef.current.files on demand, or handle the onChange event and store e.target.files in state. The files property is a FileList — use Array.from(e.target.files) to convert it to a plain array. Add the multiple attribute to the input for selecting multiple files.
function FileUpload() {
  const handleChange = (e) => {
    const file = e.target.files[0];
    if (file) {
      console.log('Selected:', file.name, file.size);
      const formData = new FormData();
      formData.append('file', file);
      // upload formData...
    }
  };

  return <input type="file" onChange={handleChange} accept=".pdf,.jpg" />;
}
Use the accept attribute to filter file types. Access e.target.files for the FileList of selected files.

Why it matters: File inputs work differently — the value can't be set by React state, so you must always read it from the input's event.

Real applications: Profile picture uploads, document attachments in forms, drag-and-drop file upload zones.

Common mistakes: Trying to set value on a file input (React doesn't allow it for security reasons) or forgetting the multiple attribute for multi-file selection.

React Hook Form and Formik are the two most popular form libraries in the React ecosystem. React Hook Form uses an uncontrolled approach with refs internally, so most inputs never cause a re-render as the user types — making it significantly faster for large forms. Formik uses a controlled state-based approach and provides helpers for validation, submission, touched/error tracking, and field arrays. Both integrate with schema validation libraries like Yup or Zod. For most new projects, React Hook Form is preferred for its performance and smaller bundle size.
// React Hook Form example
import { useForm } from 'react-hook-form';

function MyForm() {
  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>}
    </form>
  );
}
React Hook Form uses uncontrolled inputs with refs for better performance. Formik uses controlled inputs. Both integrate with Yup or Zod for schema validation.

Why it matters: For complex forms with many fields or nested validation, building everything from scratch is time-consuming and error-prone.

Real applications: Multi-step forms, dynamic field arrays, complex validation schemas in enterprise apps.

Common mistakes: Using a heavy form library for a simple two-field login form — sometimes plain useState is all you need.

Checkboxes are controlled with the checked prop (a boolean) rather than value, and the handler reads e.target.checked to toggle state. For a group of radio buttons, all options share one state variable representing the selected value; each button's checked prop compares the option's value against the state. Setting checked without an onChange handler makes the input read-only and React will log a warning. Use defaultChecked instead of checked for uncontrolled checkboxes where you don't need to track the value in state.
const [agree, setAgree] = useState(false);
const [plan, setPlan] = useState('free');

<label>
  <input type="checkbox" checked={agree}
    onChange={e => setAgree(e.target.checked)} /> I agree
</label>

<label>
  <input type="radio" value="free" checked={plan === 'free'}
    onChange={e => setPlan(e.target.value)} /> Free
</label>
<label>
  <input type="radio" value="pro" checked={plan === 'pro'}
    onChange={e => setPlan(e.target.value)} /> Pro
</label>
For checkboxes use e.target.checked; for radio buttons use e.target.value. Both remain fully controlled through React state.

Why it matters: Checkboxes and radio buttons work differently from text inputs — they use checked and value in distinct ways.

Real applications: Terms and conditions checkboxes, newsletter subscription options, selecting a shipping method with radio buttons.

Common mistakes: Using e.target.value for a checkbox (it always returns "on") instead of e.target.checked.

To reset a controlled form, set all state values back to their initial defaults — since React state drives the inputs, resetting state resets the displayed form instantly. For an uncontrolled form, call the native formRef.current.reset() on the form DOM element. A clean technique that works for both: pass a new key prop to the form component to make React unmount and remount it entirely, clearing all inputs and state without manual reset logic.
function ContactForm() {
  const [form, setForm] = useState({ name: '', email: '', message: '' });

  const handleSubmit = async (e) => {
    e.preventDefault();
    await sendMessage(form);
    // Reset all fields back to empty
    setForm({ name: '', email: '', message: '' });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={form.name} onChange={handleChange} />
      <input name="email" value={form.email} onChange={handleChange} />
      <textarea name="message" value={form.message} onChange={handleChange}/>
      <button type="submit">Send</button>
    </form>
  );
}
Resetting controlled state immediately clears all inputs because every input's value is driven by React state.

Why it matters: Resetting a controlled form is simple — just set all state values back to empty strings or initial values.

Real applications: Clearing a comment form after posting, resetting a search filter, clearing a contact form after successful submission.

Common mistakes: Trying to use e.target.reset() on a controlled form — this won't work because React owns the values, not the DOM.

Real-time validation runs on onChange and checks the current value every time the user types, storing error messages in a separate errors state object keyed by field name. A common UX pattern is to only show errors for fields the user has already touched (tracked with a touched state object), so fresh empty fields don't immediately show error messages. Clear the error for a field as soon as the user enters valid input to give immediate positive feedback.
function EmailInput() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  const handleChange = (e) => {
    const value = e.target.value;
    setEmail(value);
    // Validate as user types
    if (value && !value.includes('@')) {
      setError('Please enter a valid email address');
    } else {
      setError('');
    }
  };

  return (
    <div>
      <input value={email} onChange={handleChange} />
      {error && <span className="error">{error}</span>}
    </div>
  );
}
Real-time validation gives instant feedback as users type. For submit-only validation run the check inside handleSubmit instead.

Why it matters: Showing errors as the user types helps them fix mistakes immediately without having to submit the form first.

Real applications: Password strength indicators, email format validation, username availability checks.

Common mistakes: Showing errors immediately before the user has typed anything — only show errors after the field has been touched.

Adding the multiple attribute to <select> allows users to select more than one option with Ctrl/Cmd+click. When controlled, state must be an array of selected values rather than a single string. In the onChange handler, convert the e.target.selectedOptions (an HTMLCollection) to an array with Array.from() and map each option to its value. Each <option>'s selected state is determined by whether its value exists in the state array.
const [selected, setSelected] = useState([]);

const handleChange = (e) => {
  // Convert the HTMLOptionsCollection to an array of values
  const values = Array.from(e.target.selectedOptions, opt => opt.value);
  setSelected(values);
};

<select multiple value={selected} onChange={handleChange}>
  <option value="react">React</option>
  <option value="vue">Vue</option>
  <option value="angular">Angular</option>
</select>

<p>Selected: {selected.join(', ')}</p>
Users hold Ctrl/Cmd to select multiple options. The value prop on a multi-select expects an array of selected values.

Why it matters: Multi-select is tricky because value must be an array, not a string like a regular select.

Real applications: Selecting multiple skills in a profile form, choosing multiple categories for a post, filtering by multiple tags.

Common mistakes: Passing a string instead of an array to the value prop on a multi-select — the selections won't show correctly.

onChange validation checks the field on every keystroke and gives immediate real-time feedback — ideal for confirming password match, checking username availability, or enforcing number ranges. onBlur validation checks the field only when the user moves focus away, which is less intrusive and avoids showing errors before the user finishes typing. A common pattern combines both: validate on blur first, then re-validate on change once the field has been touched, so errors clear in real time but don't appear prematurely.
function NameInput() {
  const [name, setName] = useState('');
  const [error, setError] = useState('');

  const validate = (value) => {
    if (!value.trim()) setError('Name is required');
    else setError('');
  };

  return (
    <div>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
        onBlur={e => validate(e.target.value)} // validate on leave
      />
      {error && <span>{error}</span>}
    </div>
  );
}
A common UX pattern: show errors on blur first, then switch to onChange validation once the field has been touched.

Why it matters: When you show validation errors affects how friendly the form feels — too early is annoying, too late is frustrating.

Real applications: Email validation that starts on blur so it doesn't interrupt typing, live character count that updates as you type.

Common mistakes: Showing validation errors immediately when the page loads before the user has interacted with any field.

Computing an isValid boolean from your current state values and passing it to the button's disabled prop gives users clear visual feedback that the form is not ready. This is more user-friendly than only showing errors after they click submit. Define isValid by checking that all required fields are non-empty and all conditions pass — derived inline from state without any extra state variables. Combine with styles or CSS classes to visually differentiate the enabled and disabled states beyond the browser's default appearance.
function SignupForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const isValid = email.includes('@') && password.length >= 8;

  return (
    <form>
      <input value={email} onChange={e => setEmail(e.target.value)}
        placeholder="Email" />
      <input type="password" value={password}
        onChange={e => setPassword(e.target.value)} placeholder="Password" />
      <button type="submit" disabled={!isValid}>
        Sign Up
      </button>
    </form>
  );
}
Because isValid is a derived value computed every render, the button stays in perfect sync with the form state automatically.

Why it matters: Disabling the submit button when the form is invalid prevents users from submitting incomplete or invalid data.

Real applications: Login buttons that stay disabled until both email and password are filled, checkout buttons waiting for required fields.

Common mistakes: Only checking one condition for isValid and forgetting others, leaving the button enabled when the form still has errors.