Senior 13 min · March 05, 2026

React Controlled Components — Checkbox Always True

Checkbox always stores 'on' due to event.target.value — the #1 React bug.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • React state owns the input's value — the DOM just displays it.
  • Two requirements: value prop bound to state, and onChange handler that updates state.
  • Single form state object with generic handler scales to any number of fields.
  • Miss onChange and the input becomes read-only — React logs a warning in dev.
  • Common pitfall: starting state as null causes React to treat it as uncontrolled first.
✦ Definition~90s read
What is React Forms and Controlled Components?

A controlled component in React is one where the component's state is managed by React itself, not the DOM. When you write <input type="checkbox" checked={isChecked} onChange={handleChange} />, you're explicitly telling React: "I own this checkbox's state, and it will only change when I say so." This is the core contract of controlled components — the component's value is always driven by a React state variable, and any user interaction must flow through an event handler that updates that state.

Imagine a puppet show where the puppeteer controls every single movement of the puppet — the puppet never moves on its own.

The "checkbox always true" bug happens when developers violate this contract, typically by setting checked to a constant true or forgetting to wire up the onChange handler to toggle the state. Without that handler, React sees the state never change, so the checkbox stays stuck in its initial state — a classic footgun that trips up everyone from junior devs to seasoned engineers migrating from jQuery or vanilla JS.

Controlled components exist to solve the fundamental problem of "who owns the truth?" in React's declarative paradigm. In an uncontrolled input, the DOM owns the state — you read it via a ref or form submission. This works for simple cases but breaks down fast when you need validation, conditional rendering, or multi-field coordination.

Controlled inputs give you a single source of truth in your component's state, making it trivial to validate on every keystroke, disable a submit button until all fields are valid, or sync a checkbox with other UI elements. The trade-off is more boilerplate — every input needs its own state variable and handler — but libraries like Formik (used by 40%+ of React developers) and React Hook Form abstract this pattern while keeping the controlled contract intact.

Where controlled components shine is in complex forms with interdependent fields, real-time validation, or dynamic field arrays. Don't use them for simple, one-off forms where you just need to grab values on submit — uncontrolled inputs with ref or form data are faster to write and avoid unnecessary re-renders.

React's official docs recommend controlled components for most cases, but the ecosystem has evolved: React Hook Form uses uncontrolled inputs under the hood with refs for performance, then exposes a controlled-like API. The key insight is understanding the contract — once you internalize that React must be the sole source of truth for any controlled input, you'll stop fighting the framework and start building forms that behave predictably across checkboxes, selects, textareas, and custom components alike.

Plain-English First

Imagine a puppet show where the puppeteer controls every single movement of the puppet — the puppet never moves on its own. A controlled component in React is exactly that: React (the puppeteer) owns and controls the value inside every input field at all times. The input never gets to decide what it shows — React does. This is the opposite of an uncontrolled input, which is like a puppet that moves itself and you just check on it occasionally.

Forms are the front door of almost every real application. Login pages, checkout flows, search bars, profile editors — they all live and die by how reliably they handle user input. Get this wrong and you end up with out-of-sync UI, impossible-to-test logic, and bugs that only appear on slow networks or after a double-click. This is not a theoretical concern; it's the number one source of subtle bugs in junior-to-mid React codebases.

React's answer to this problem is the controlled component pattern. Before it existed, developers had to reach into the DOM with refs or query selectors to find out what a user typed — the same fragile approach that made jQuery apps notoriously hard to debug. Controlled components flip the model: the input's displayed value is always derived from React state, and every keystroke fires a handler that updates that state. The DOM is never the source of truth. React is.

By the end of this article you'll understand exactly why the controlled pattern was designed the way it was, how to build a fully validated multi-field form from scratch, how to handle edge cases like checkboxes and selects, and the specific mistakes that trip up developers who think they already know this stuff. You'll also walk away with the mental model that makes debugging any form issue fast and obvious.

Why Your Checkbox Stays True — The Controlled Component Contract

A controlled component in React is one where the form element's value is driven by component state, not the DOM. The input displays whatever the state says — always. The checkbox stays checked because you set checked={isChecked} and never update state on change. This is the core mechanic: React state is the single source of truth, and the DOM is just a reflection.

To make a controlled checkbox work, you must wire both checked and onChange. checked reads from state; onChange writes back. Miss either one and the checkbox becomes either read-only (no onChange) or uncontrolled (no checked prop). The common mistake: setting defaultChecked instead of checked. defaultChecked only sets the initial value — after that, React ignores it, and the DOM takes over. The checkbox appears to work until a re-render, then snaps back to the initial value.

Use controlled components when you need to validate, transform, or react to every keystroke or toggle — search inputs, multi-step forms, or any field whose value must be coordinated with other UI. Avoid them for simple, one-off forms where uncontrolled with a ref is simpler. The cost: every keystroke triggers a re-render. For high-frequency inputs (sliders, real-time search), debounce or switch to uncontrolled with refs.

The defaultChecked Trap
Using defaultChecked instead of checked makes the checkbox uncontrolled after mount — React will not update it on re-render, causing stale UI.
Production Insight
A team shipped a settings panel where toggling a checkbox didn't persist after navigating away and back — they used defaultChecked and never wired onChange.
The symptom: checkbox appeared to toggle, but on re-mount it reverted to the initial value from props, confusing users and causing data loss.
Rule of thumb: if the value must survive re-renders or be read by other components, use checked + onChange — never defaultChecked.
Key Takeaway
Controlled components require both value/checked and onChange — one without the other breaks the contract.
defaultChecked is for uncontrolled components only; using it in a controlled pattern creates a silent bug.
Every controlled input re-renders on change — profile performance and debounce when necessary.
React Controlled Components: Checkbox Always True THECODEFORGE.IO React Controlled Components: Checkbox Always True Flow from uncontrolled to controlled pattern with checkbox fix Uncontrolled Checkbox defaultChecked sets initial, not state Controlled Component value/checked from state, onChange handler State Update Cycle setState triggers re-render with new value Multi-Field Form single onChange with name attribute Validation & Submit validate state before submission ⚠ Missing onChange: checkbox stays true Always provide onChange handler for controlled inputs THECODEFORGE.IO
thecodeforge.io
React Controlled Components: Checkbox Always True
React Forms Controlled Components

Why Uncontrolled Inputs Break Down (and What Controlled Inputs Fix)

In a plain HTML page, an input element owns its own value. You type, the DOM updates, and your JavaScript finds out by reading inputElement.value. This works fine for a static page, but React is built around one core rule: the UI is a function of state. If the DOM owns the value, React doesn't know about it — and the moment you try to do anything reactive (validation, formatting, conditional rendering based on what the user typed), you're fighting the framework.

Here's the concrete problem. Say you want to enforce that a username field is always lowercase. With an uncontrolled input, you'd have to intercept keystrokes, manipulate the DOM directly, and pray that mobile autocorrect doesn't bypass your handler. With a controlled input, it's one line: store the value as lowercase in state, and the input will always display lowercase — no DOM wrestling required.

The controlled pattern also makes testing dramatically easier. Because the form's entire state lives in plain JavaScript objects, you can test every validation rule without rendering a browser. That's the real payoff: React state is inspectable, serializable, and predictable. The DOM is none of those things.

UsernameInput.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { useState } from 'react';

export default function UsernameInput() {
  // React owns the value — NOT the DOM
  const [username, setUsername] = useState('');

  function handleUsernameChange(event) {
    // We transform the value BEFORE storing it.
    // The input will always display what React has in state.
    const rawValue = event.target.value;
    const sanitised = rawValue.toLowerCase().replace(/\s/g, ''); // no spaces, always lowercase
    setUsername(sanitised);
  }

  return (
    <div>
      <label htmlFor="username">Username</label>
      {/* value={username} makes this a controlled input.
          Without this prop it becomes uncontrolled. */}
      <input
        id="username"
        type="text"
        value={username}          // ← React dictates what the input displays
        onChange={handleUsernameChange} // ← every keystroke goes through here
        placeholder="e.g. janedoe"
      />
      <p>Stored value: "{username}"</p>
    </div>
  );
}
Output
User types: "Jane Doe 123"
Stored value displayed below input: "janedoe123"
(Spaces are stripped and uppercase is converted in real time)
The Controlled Component Contract:
An input is controlled when two conditions are both true: (1) it has a value prop tied to state, AND (2) it has an onChange handler that updates that state. Miss either one and React will warn you — or worse, silently behave in confusing ways.
Production Insight
A production bug: Username field that didn't normalize to lowercase caused duplicate user accounts across different email cases.
Fix: controlled input with onChange handler that transforms value before setState — guaranteed normalization at the source.
Rule: if you ever read the DOM for an input's value, you've lost React's reactivity. Use controlled components to keep the state as the single source of truth.
Key Takeaway
If you ever read the DOM for an input's value, you've lost React's reactivity.
Controlled inputs eliminate DOM reads entirely.
React state is the single source of truth for displayed values.

React Controlled Component Lifecycle Flow

Understanding the exact sequence of events in a controlled component is crucial for debugging. When a user types into an input, a chain reaction starts: the browser fires a synthetic event, React calls your onChange handler, you update state, and React triggers a re-render with the new value. The DOM then displays the updated value. This cycle repeats on every keystroke.

The diagram below illustrates the flow from user input to screen update. Notice that React sits between the user's action and the DOM update — that's exactly how it maintains control. If you break any link in this chain (missing onChange, stale closure, or direct DOM manipulation), the flow halts and the UI becomes out of sync.

Key Debugging Insight:
If the input does not update, check each link in this chain: Is the event firing? Is setState called with the correct value? Is the value prop bound to the state variable? React DevTools makes each step inspectable.
Production Insight
A common production issue: the handler uses a stale closure capturing an old value of state. For example, using setState(state + 1) inside a useEffect without proper dependencies. The fix is to use the functional form setState(prev => prev + 1) to ensure you always have the latest state.
Key Takeaway
The controlled component lifecycle is a closed loop: input → event → handler → setState → re-render → updated input. Debug by isolating each step.
Controlled Component Lifecycle
onInput eventcalls onChange handlercalls setState with new valuetriggers re-rendernew value prop passedUser Types in InputReact captures SyntheticEventonChange function incomponentState updated with setStateComponent re-rendersInput element displays newvalue

Building a Real Multi-Field Form with Validation

Most tutorials show a single input. Real apps have forms with five, ten, or twenty fields — and managing a separate useState call for each one becomes a maintenance nightmare fast. The cleaner production pattern is to store all field values in a single state object, use a generic change handler that reads the input's name attribute, and keep validation errors in a parallel object with matching keys.

This pattern scales because adding a new field means adding one key to your initial state object and one validation rule — nothing else changes. The handler and the error display logic are already written.

Validation belongs in a separate function that takes the current form values and returns an errors object. Keeping validation pure (no side effects, no DOM access) means you can unit test every rule in isolation. Only call this function on submit or on blur — validating on every keystroke is usually annoying for users unless you're checking something like password strength where live feedback adds value.

RegistrationForm.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import { useState } from 'react';

// Pure validation function — easy to unit test independently
function validateRegistration(fields) {
  const errors = {};

  if (!fields.email.includes('@')) {
    errors.email = 'Please enter a valid email address.';
  }

  if (fields.password.length < 8) {
    errors.password = 'Password must be at least 8 characters.';
  }

  if (fields.password !== fields.confirmPassword) {
    errors.confirmPassword = 'Passwords do not match.';
  }

  return errors; // empty object means no errors
}

export default function RegistrationForm() {
  // All field values in ONE state object — scales cleanly
  const [formValues, setFormValues] = useState({ email: '', password: '', confirmPassword: '' });

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

  // Generic handler: reads event.target.name to know WHICH field changed
  function handleFieldChange(event) {
    const { name, value } = event.target;
    setFormValues((previousValues) => ({\n      ...previousValues,   // keep all other fields unchanged\n      [name]: value,       // update only the field that triggered the event\n    }));
  }

  function handleSubmit(event) {
    event.preventDefault(); // stop the browser reloading the page

    const validationErrors = validateRegistration(formValues);

    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors); // show errors, do NOT submit
      return;
    }

    // At this point the form is valid
    setErrors({});
    setIsSubmitted(true);
    console.log('Submitting registration:', formValues);
  }

  if (isSubmitted) {
    return <p>Thanks for registering, {formValues.email}!</p>;
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          name="email"          // ← MUST match the key in formValues
          value={formValues.email}
          onChange={handleFieldChange}
        />
        {/* Only show an error message if one exists for this field */}
        {errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          name="password"
          value={formValues.password}
          onChange={handleFieldChange}
        />
        {errors.password && <span style={{ color: 'red' }}>{errors.password}</span>}
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password</label>
        <input
          id="confirmPassword"
          type="password"
          name="confirmPassword"
          value={formValues.confirmPassword}
          onChange={handleFieldChange}
        />
        {errors.confirmPassword && (
          <span style={{ color: 'red' }}>{errors.confirmPassword}</span>
        )}
      </div>

      <button type="submit">Create Account</button>
    </form>
  );
}
Output
Scenario 1 — User submits with password 'abc' and no @ in email:
Error shown under email: "Please enter a valid email address."
Error shown under password: "Password must be at least 8 characters."
Form does NOT submit.
Scenario 2 — User fills in valid data:
Console: Submitting registration: { email: 'jane@example.com'
Pro Tip — The name Attribute is the Bridge:
The name attribute on your input MUST exactly match the key in your state object for the generic handler pattern to work. A mismatch creates a new key instead of updating the right one — and React won't warn you about it. Name your inputs deliberately.
Production Insight
On slow devices, validating every keystroke with expensive rules (like password strength) causes UI jank.
Fix: validate on blur or on submit, not on every change. Only validate live for fields that benefit from it.
Rule: pure validation functions are testable and predictable — keep them separate from component logic.
Key Takeaway
One state object + one generic handler = maintainable forms.
Adding a field means adding one key and one validation rule.
Validate on blur or submit, not every keystroke — unless the field needs live feedback.

Handling Checkboxes, Selects and Textarea — The Parts That Trip People Up

Text inputs are straightforward once you get the controlled pattern, but checkboxes, radio buttons, and select dropdowns each have a small twist that catches people out.

For checkboxes, the value you care about isn't event.target.value — it's event.target.checked, a boolean. If you accidentally read .value you'll get the string 'on' regardless of whether the box is ticked or not. That's a classic bug.

For <select> elements, the controlled pattern looks identical to a text input: bind value to state and update via onChange. React handles matching the selected option automatically — you don't need to manually set selected on each <option> tag the way you'd do in plain HTML.

For <textarea>, React diverges from HTML. In HTML, textarea content goes between the tags. In React, you use the value prop, just like an input. This consistency is intentional — once you know the controlled pattern, it works the same way across all form elements.

PreferencesForm.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { useState } from 'react';

export default function PreferencesForm() {
  const [preferences, setPreferences] = useState({\n    newsletter: false,        // checkbox — boolean, not string\n    experienceLevel: 'junior', // select dropdown\n    bio: '',                  // textarea\n  });

  function handleChange(event) {
    const { name, value, type, checked } = event.target;

    setPreferences((prev) => ({\n      ...prev,\n      // For checkboxes use `checked`; for everything else use `value`\n      [name]: type === 'checkbox' ? checked : value,\n    }));
  }

  function handleSubmit(event) {
    event.preventDefault();
    console.log('Saved preferences:', preferences);
  }

  return (
    <form onSubmit={handleSubmit}>

      {/* CHECKBOX — bind to `checked`, not `value` */}
      <div>
        <label>
          <input
            type="checkbox"
            name="newsletter"
            checked={preferences.newsletter} // ← `checked` not `value`
            onChange={handleChange}
          />
          Subscribe to newsletter
        </label>
      </div>

      {/* SELECT — works exactly like a text input */}
      <div>
        <label htmlFor="experienceLevel">Experience Level</label>
        <select
          id="experienceLevel"
          name="experienceLevel"
          value={preferences.experienceLevel} // React auto-highlights the matching option
          onChange={handleChange}
        >
          <option value="junior">Junior (02 years)</option>
          <option value="mid">Mid (25 years)</option>
          <option value="senior">Senior (5+ years)</option>
        </select>
      </div>

      {/* TEXTAREA — use `value` prop, NOT children */}
      <div>
        <label htmlFor="bio">Short Bio</label>
        <textarea
          id="bio"
          name="bio"
          value={preferences.bio} // ← same pattern as input, not between tags
          onChange={handleChange}
          rows={4}
        />
      </div>

      <button type="submit">Save Preferences</button>
    </form>
  );
}
Output
User ticks newsletter, selects 'mid', types 'I love React'.
On submit, console logs:
{\n newsletter: true,\n experienceLevel: 'mid',\n bio: 'I love React'\n}
Watch Out — Checkbox Value vs Checked:
Reading event.target.value on a checkbox always returns the string 'on', whether ticked or not. You'll store 'on' in state and your condition if (preferences.newsletter) will always be truthy. Always use event.target.checked for checkboxes.
Production Insight
Mobile autocorrect can bypass onChange on some browsers for text inputs, but for controlled inputs it's impossible — React always overwrites the displayed value from state.
However, for checkboxes, the browser fires onChange reliably, but reading value instead of checked silently corrupts data.
Rule: always use event.target.checked for checkboxes; test with both ticked and unticked states in QA.
Key Takeaway
Checkboxes: use event.target.checked — always a boolean.
Select: same as text input — value prop driven by state.
Textarea: use value prop, not children.
Each form element has one consistent controlled pattern.

Controlled vs Uncontrolled — When Each Pattern Actually Makes Sense

Controlled components are the right default for most forms. But there are legitimate reasons to reach for uncontrolled inputs using useRef — and knowing when to make that call is what separates intermediate from senior React thinking.

The strongest case for uncontrolled inputs is file uploads. The file input's value can only be set by the user for security reasons — you can't programmatically assign it. So React simply cannot control it. You always use a ref and read ref.current.files on submit.

Another case is integrating with non-React libraries — things like rich text editors (Quill, Slate) or date pickers that manage their own internal DOM. Forcing a controlled wrapper around them often causes re-render conflicts. In those situations, uncontrolled refs are the pragmatic choice.

The mental model: if you need to read the value on submit only, uncontrolled is simpler. If you need the value continuously — for live validation, conditional rendering, or derived computations — controlled is the right tool. Don't use controlled components just because they're the 'React way'; use them because your feature genuinely needs that continuous sync.

FileUploadForm.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { useRef } from 'react';

export default function FileUploadForm() {
  // useRef — we're NOT controlling this input, just reading it on submit
  const fileInputRef = useRef(null);

  function handleUploadSubmit(event) {
    event.preventDefault();

    // Access the files array directly from the DOM node via the ref
    const selectedFiles = fileInputRef.current.files;

    if (selectedFiles.length === 0) {
      alert('Please select a file first.');
      return;
    }

    const firstFile = selectedFiles[0];
    console.log('File name:', firstFile.name);
    console.log('File size (bytes):', firstFile.size);
    console.log('File type:', firstFile.type);

    // In production you'd send this to an API with FormData
  }

  return (
    <form onSubmit={handleUploadSubmit}>
      {/* File inputs CANNOT be controlled — use a ref instead */}
      <input
        type="file"
        ref={fileInputRef}
        accept="image/*"
      />
      <button type="submit">Upload</button>
    </form>
  );
}
Output
User selects 'profile-photo.png' (42,500 bytes).
On submit:
File name: profile-photo.png
File size (bytes): 42500
File type: image/png
Interview Gold — The File Input Exception:
Interviewers love asking 'can all inputs be controlled?' The answer is no: file inputs are read-only from JavaScript by design (a browser security rule). They must be handled with refs. Knowing this exception — and why it exists — signals solid understanding.
Production Insight
Wrapping a third-party rich text editor as a controlled component caused infinite re-renders — the editor modified the DOM directly, which React detected and overwrote, triggering another editor update.
Fix: use an uncontrolled ref for the editor node and sync state only on blur or when the editor emits a change event.
Rule: controlled components are for React-owned state. If the DOM/library owns the state, don't try to control it — use refs and sync when necessary.
Key Takeaway
Controlled is the default for most inputs.
Uncontrolled is required for file inputs and safer for third-party DOM libraries.
Choose based on who owns the state: React or the DOM.

When to Use Controlled vs Uncontrolled: Decision Criteria

Choosing between controlled and uncontrolled components depends on your specific use case. Use the following decision criteria to make the right choice in production.

Use Controlled Components When: - You need real-time validation (e.g., password strength indicator, email format check on every keystroke). - You need to conditionally render UI based on input values (e.g., show/hide fields based on a dropdown selection). - You need to transform input (e.g., force uppercase, remove whitespace, format currency). - You need to test form logic without a browser — pure state makes unit tests trivial. - You have a multi-field form where fields interact (e.g., confirm password must match password).

Use Uncontrolled Components When: - You only need the value once (e.g., on form submit). - The input is a file upload — browsers prohibit programmatic value setting. - You are integrating with a third-party library that manages its own DOM (rich text editors, date pickers). - You need minimal re-renders for performance-critical sections (though measure first — premature optimisation is the root of all evil). - You have a simple single-field form with no validation or transformation needed.

Decision Flowchart: If you need to decide at a glance, follow this logic: 1. Is the input a file? → Uncontrolled (it must be). 2. Do you need the value continuously (live validation/formatting/reactive UI)? → Controlled. 3. Is the input managed by a non-React library? → Uncontrolled (or wrap with careful syncing). 4. Otherwise → Start with controlled — it's the idiomatic React pattern and you can always refactor to uncontrolled later if performance becomes an issue.

Production Insight
In a high-traffic dashboard, a single controlled input with expensive onChange handler caused jank on every keystroke. The developer switched to uncontrolled and only synced state on blur, which cut re-renders by 90%. Moral: measure first, then optimise. Always start with controlled unless you have a measurable perf problem.
Key Takeaway
Start with controlled components by default. Switch to uncontrolled only when there's a clear reason: file inputs, third-party libraries, or measured performance bottlenecks.
Decision Flowchart
YesNoYesNoYesNoForm input neededIs it a file input?Use Uncontrolled with refNeed value continuously?Use ControlledManaged by non-React library?Use UncontrolledDefault: Use Controlled

File Input Handling: The Uncontrolled Exception

File inputs are the one type of input that can never be controlled in React — and for good reason. Browser security models prevent JavaScript from programmatically setting the value of a file input (e.g., input.value = '/etc/passwd'). This protects users from malicious scripts that could silently read sensitive files. As a result, React's controlled pattern simply cannot apply to file inputs.

  1. Use useRef to create a reference to the input element.
  2. On form submit, read ref.current.files to get the FileList object.
  3. Validate file type and size before sending to the server.
  4. Use FormData to upload the file via fetch or XMLHttpRequest.
Best practices
  • Always specify accept attribute to guide the user (e.g., accept="image/*").
  • Allow multiple files if needed with the multiple attribute.
  • Provide immediate feedback on file selection (show filename, size preview).
  • Never trust client-side validation alone — always validate on the server side as well.

The file input is the classic interview question: "Can all inputs be controlled?" Now you know the answer: no, and knowing why (browser security) shows deep understanding.

FileUploadWithValidation.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useRef, useState } from 'react';

export default function FileUploadWithValidation() {
  const fileInputRef = useRef(null);
  const [fileInfo, setFileInfo] = useState(null);

  function handleFileChange() {
    const files = fileInputRef.current.files;
    if (files.length > 0) {
      const file = files[0];
      // Validate before showing info
      if (!file.type.startsWith('image/')) {
        alert('Only images are allowed.');
        return;
      }
      if (file.size > 5 * 1024 * 1024) { // 5MB
        alert('File size must be less than 5MB.');
        return;
      }
      setFileInfo({ name: file.name
Output
User selects a 3MB PNG file:
"Selected: profile.png (3072.0 KB)"
On submit, sends FormData to /upload-avatar.
If user selects a PDF:
Alert: "Only images are allowed."
Security Reminder:
Client-side file validation (type, size) is for UX only. Always re-validate on the server side. An attacker can bypass client checks entirely.
Production Insight
A common production bug: developers try to make file inputs controlled by reading event.target.value on change, but it always returns an empty string (or the file path in old browsers). The fix is to abandon controlled patterns for file inputs and rely on refs. Always use ref.current.files to access the actual file data.
Key Takeaway
File inputs cannot be controlled due to browser security. Use useRef and read ref.current.files on submit. Validate client-side for UX, but always re-validate server-side.

Form Library Alternatives: Formik and React Hook Form

While controlled components with useState work well for small to medium forms, complex forms with deep nesting, async validation, or hundreds of fields can become unwieldy. That's where form libraries like Formik and React Hook Form shine. They abstract away the boilerplate of controlled components while still giving you fine-grained control over state and validation.

Formik is the older, more established library. It provides a <Formik> component and useFormik hook that manage form state, validation, and submission. Its strengths are: - Built-in error tracking and touched state. - Easy integration with validation libraries like Yup. - Explicit, readable code that mirrors controlled component patterns.

React Hook Form is newer and often preferred for performance. It uses uncontrolled inputs by default (minimising re-renders) and provides a register function to attach inputs. Its strengths are: - Minimises re-renders by using refs under the hood. - Simple API with register and handleSubmit. - Excellent performance for large forms.

Both libraries support validation schemas (Yup, Zod), dynamic fields, and custom validation. They are fully fledged solutions that can replace custom controlled implementations.

When to consider a library: - Your form has more than 10 fields - You need complex async validation (e.g., check username availability) - You have deeply nested field arrays that users can add/remove dynamically - You need cross-field validation rules that depend on the values of multiple fields - You want built-in error handling and touched state without manual management

When to stick with plain controlled components: - Simple forms (login, contact, search) - You want zero dependencies - You need full control over every aspect of form state - Your team is new to React and you want to avoid an extra abstraction layer

ReactHookFormExample.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { useForm } from 'react-hook-form';

export default function LoginFormRHF() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm();

  async function onSubmit(data) {
    // data is validated plain object
    console.log('Login data:', data);
    // Simulate API call
    await new Promise((resolve) => setTimeout(resolve, 1000));
    alert('Logged in!');
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          {...register('email', {\n            required: 'Email is required',\n            pattern: {\n              value: /^\\\S+@\\\S+$/i,\n              message: 'Invalid email format',\n            },
          })}
        />
        {errors.email && <span>{errors.email.message}</span>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          {...register('password', {\n            required: 'Password is required',\n            minLength: { value: 8, message: 'At least 8 characters' },\n          })}
        />
        {errors.password && <span>{errors.password.message}</span>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}
Output
User submits with blank email and 'abc':
Error under email: "Email is required"
Error under password: "At least 8 characters"
User submits valid data:
Console: Login data: { email: 'user@example.com'
Library Decision Guide:
If you like the controlled component mental model and want explicit state management, use Formik. If you prioritise performance and minimal re-renders, use React Hook Form. Both are production-proven and well-maintained.
Production Insight
A team migrated a 50-field registration form from plain controlled state to React Hook Form. Re-render count dropped by 85% and form state complexity went from 400 lines of hand-rolled logic to 50 lines of declarative code. The trade-off: team had to learn a new API and handle edge cases like dynamic field arrays differently. For most projects, the performance gain is worth it.
Key Takeaway
Form libraries like Formik and React Hook Form reduce boilerplate and improve performance for complex forms. Use them when your form grows beyond 10 fields or requires advanced validation. For simple forms, plain controlled components are perfectly fine.

Practice Exercises to Solidify Understanding

The best way to master controlled components is to build them. Below are five exercises ranging from basic to advanced. Each exercise focuses on a key concept covered in this article. Attempt each one before looking at the solution hints.

Exercise 1: Simple Login Form Build a login form with username and password fields. Both should be controlled components. Display an error message if the username is empty on submit. On successful submit, log the form data to console. - Concepts: controlled inputs, basic validation, preventDefault.

Exercise 2: Registration Form with Confirm Password Create a registration form with email, password, and confirm password. All fields controlled. The form should: - Show an inline error if passwords don't match (on blur, not on every keystroke). - Require email to contain '@'. - Show a success message after valid submission. - Concepts: multi-field state, cross-field validation, blur validation.

Exercise 3: Dynamic Field Addition (Todo List) Build a todo list form where users can add multiple items. Each item is a text input controlled by state stored in an array. Include an "Add Item" button that appends a new empty input, and a "Remove" button next to each item. On submit, log the array of items. - Concepts: dynamic arrays, unique keys (use index for simplicity, but note the caveat), generic handler with index.

Exercise 4: Conditional Fields Based on Dropdown Create a form with a dropdown that asks "Do you have a pet?" (Yes/No). If Yes, show additional fields: pet name, pet type (select), and a file input for a pet photo. The pet photo field is uncontrolled. On submit, log all data. - Concepts: conditional rendering based on controlled select, combining controlled and uncontrolled fields.

Exercise 5: Full Registration with Form Library Rewrite Exercise 2 (Registration Form) using React Hook Form and Zod validation schema. Compare the code complexity and number of re-renders (use React DevTools profiler). - Concepts: library integration, schema validation, performance comparison.

Solution Hints (if stuck): - For Exercise 3: Use useState with an array of objects, each with a unique id and value key. On change, use map to update the matching item. - For Exercise 4: Conditionally render the pet fields based on the value of the "hasPet" dropdown state. The file input uses ref, not state. - For Exercise 5: Install react-hook-form and zod, use useForm with a Zod resolver.

Learning Strategy:
Don't just copy-paste solutions. Try each exercise from scratch, then compare with the article's pattern. The mistakes you make are where the real learning happens.
Production Insight
Many junior developers skip exercises and immediately start building production forms. The result: they repeat the same bugs (checkbox 'on', uncontrolled-to-controlled warnings) for weeks. Investing 2 hours in these exercises will save you days of debugging later.
Key Takeaway
Practice is essential to internalise controlled component patterns. Build these exercises in order to progressively learn: basic → validation → dynamic fields → conditional → library integration.

The Hidden Cost of Controlled Components: Performance at Scale

Every keystroke in a controlled component triggers a re-render. That's fine for a login form with three fields. But when you're building a spreadsheet-like interface with 50 inputs, or a dynamic invoice builder, that overhead compounds fast.

React's reconciliation algorithm diffes the virtual DOM, but the real bottleneck is JavaScript execution. Each onChange handler runs, updates state, and React re-renders the entire component tree unless you've explicitly memoized children. One common rookie mistake: putting all form fields in a single state object, then watching the whole form stutter on every character typed.

The fix isn't to abandon controlled components. It's to isolate the cost. Use React.memo on child input components. Split your form into smaller state slices using useReducer or multiple useState hooks. For truly massive forms, consider a ref-based approach for high-frequency updates, then sync to state when the user leaves a field. The key insight: controlled components are a contract with predictability, not with performance. You sign that contract, but you also optimize the runtime.

MemoizedInput.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// io.thecodeforge — javascript tutorial

import React, { memo, useState, useCallback } from 'react';

const SlowInput = memo(({ name, value, onChange }) => {
  console.log(`Rendering ${name}`);
  return (
    <label>
      {name}:
      <input name={name} value={value} onChange={onChange} />
    </label>
  );
}, (prev, next) => prev.value === next.value);

export default function InvoiceForm() {
  const [fields, setFields] = useState({ item: '', qty: 0, price: 0 });
  
  const handleChange = useCallback((e) => {
    const { name, value } = e.target;
    setFields(prev => ({ ...prev, [name]: value }));
  }, []);

  return (
    <form>
      <SlowInput name="item" value={fields.item} onChange={handleChange} />
      <SlowInput name="qty" value={fields.qty} onChange={handleChange} />
      <SlowInput name="price" value={fields.price} onChange={handleChange} />
    </form>
  );
}
Output
Rendering item
Rendering qty
Rendering price
(initial render only — child re-renders suppressed via memo)
Production Trap:
A form with 30+ uncontrolled inputs using a single state object will cause a full re-render on every keystroke. Profile before you optimize, but always isolate expensive children with React.memo or useMemo.
Key Takeaway
Memoize child input components when forms exceed 10-15 fields to prevent pointless re-renders on each keystroke.

The Hidden State Leak: Forms That Sync to Redux or Global State

You see this pattern on every second React codebase: a form that directly dispatches to Redux on every onChange. The intent is 'realtime sync'. The result is a race condition nightmare and a store that looks like someone spilled a spreadsheet.

Global state managers like Redux, Zustand, or Context are built for cross-component reads and async side effects. They are not built for high-frequency updates. Every dispatch triggers selectors to re-evaluate, components re-render, and performance tanks. Worse, if the user partially fills a field and the store updates, another part of your app might fire an effect based on incomplete data — like an autosave that saves half an email address.

The senior approach: keep form state local. Use useReducer or useState inside the form component. Only dispatch to global state when the form is submitted or when you explicitly need to share data across routes (e.g., a multi-step wizard). For the latter, lift state to a parent or use a dedicated wizard context that's torn down on completion. Never sync partial inputs to your global store. The only thing you're buying is bugs.

If you absolutely must sync — perhaps for offline-first apps — batch the updates. Debounce the onChange handler by 500ms, or use a ref to accumulate changes and flush on blur. Your future self will thank you.

LocalFormState.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// io.thecodeforge — javascript tutorial

import { useState, useCallback } from 'react';
import { useDispatch } from 'react-redux';

export default function LoginForm() {
  const [localState, setLocalState] = useState({ email: '', password: '' });
  const dispatch = useDispatch();

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

  const handleSubmit = useCallback((e) => {
    e.preventDefault();
    // Only dispatch once — on submit. No partial store pollution.
    dispatch({ type: 'LOGIN_ATTEMPT', payload: localState });
  }, [localState, dispatch]);

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" value={localState.email} onChange={handleChange} />
      <input name="password" type="password" value={localState.password} onChange={handleChange} />
      <button type="submit">Login</button>
    </form>
  );
}
Output
(No Redux dispatch until submit button is clicked — form state stays local)
Senior Shortcut:
Use local state for forms. Dispatch to Redux only on submit, blur, or a deliberate 'save' action. This eliminates redundant re-renders and prevents half-empty states from leaking into your app.
Key Takeaway
Keep form state local. Dispatch to global stores only on submit or blur — never on every keystroke.
● Production incidentPOST-MORTEMseverity: high

The Checkbox That Always Said Yes

Symptom
Checkbox value always updates to true regardless of user action.
Assumption
event.target.value returns the checked state for checkboxes.
Root cause
The developer used event.target.value which always returns the string 'on' for checkboxes, instead of event.target.checked which returns the boolean.
Fix
In the generic change handler, detect checkboxes by type and use checked: `` const { name, value, type, checked } = event.target; setFormValues(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value })); ``
Key lesson
  • For checkboxes, always read event.target.checked not event.target.value.
  • The string 'on' is truthy, so this bug silently corrupts data — no error is thrown.
  • Every new engineer on the team must learn this: it's the #1 checkbox bug in React.
Production debug guideSymptom → Action5 entries
Symptom · 01
Input value doesn't change when typing.
Fix
Check that the input has both a value prop tied to state AND an onChange handler that calls the state setter with the new value.
Symptom · 02
React warning: 'A component is changing an uncontrolled input to be controlled'.
Fix
Initialize the state variable to an empty string '' instead of undefined or null.
Symptom · 03
Checkbox always stores 'on' or true regardless of tick.
Fix
Use event.target.checked for checkboxes, not event.target.value.
Symptom · 04
Select dropdown doesn't show the selected option.
Fix
Ensure the value prop on <select> matches the value of one of the <option> elements.
Symptom · 05
Form submits and page reloads, losing all state.
Fix
Add event.preventDefault() as the first line in your submit handler.
★ Controlled Component Quick DebugUse these commands and checks to fix the most common controlled component issues fast.
Input not updating
Immediate action
Open React DevTools, select the component, check if state value changes on keystroke.
Commands
In browser console: `$r.state` or inspect the component state.
Add a `console.log(event.target.value)` inside onChange to verify events fire.
Fix now
Ensure onChange calls setState with the new value: setState(event.target.value)
Uncontrolled to controlled warning+
Immediate action
Check state initialization in useState.
Commands
Search for `useState(undefined)` or `useState(null)` in your codebase.
Add a breakpoint at render and inspect the initial value prop on the input element.
Fix now
Change useState(null) to useState('') for string fields.
Checkbox always true+
Immediate action
Check the onChange handler for the checkbox.
Commands
Log the event object: `console.log(event.target.checked, event.target.value)`
In DevTools, inspect the checked attribute on the DOM input.
Fix now
Replace value with checked in the handler: setState(type === 'checkbox' ? checked : value)
Form submits and reloads page+
Immediate action
Check handleSubmit function.
Commands
Search all <form> elements that lack a submit handler with preventDefault.
Add a breakpoint in handleSubmit and step through.
Fix now
Add event.preventDefault() as the first line in handleSubmit.
Controlled vs Uncontrolled Components
AspectControlled ComponentUncontrolled Component
Source of truthReact state (useState)The DOM itself
How you read the valuestateVariable directlyref.current.value on demand
Real-time validationTrivial — state updates on every keystrokeAwkward — requires DOM polling or event listeners
Conditional rendering based on inputNatural — state drives JSXFragile — requires manual DOM reads
File inputsNot possible (browser security)Required — use useRef
TestingEasy — pure state, no DOM neededHarder — requires simulating DOM events
Integration with 3rd-party UI libsCan cause re-render conflictsOften the safer choice
BoilerplateMore — needs value + onChange on every inputLess — just a ref

Key takeaways

1
A controlled input means React state IS the value
the DOM is just the display layer. If you can't derive the current input value from state alone, you have an uncontrolled input masquerading as one.
2
One state object + one generic handleChange that reads event.target.name scales to any size form
you almost never need a separate useState per field.
3
Checkboxes use event.target.checked (boolean), not event.target.value (always the string 'on'). Mixing these up creates a bug that won't throw an error
it'll just silently store wrong data.
4
File inputs are the one place controlled components are architecturally impossible
browser security prevents JavaScript from setting a file input's value. Use useRef and read .files on submit.
5
Custom hooks encapsulate form logic for reuse. For dynamic fields, always use unique keys
never array index — to avoid stale rendering bugs.

Common mistakes to avoid

5 patterns
×

Initializing form state as `null` or `undefined`

Symptom
React logs warning: 'A component is changing an uncontrolled input to be controlled'. Input behaves as uncontrolled initially, then becomes controlled after first keystroke.
Fix
Always initialize string fields as empty string '' instead of null or undefined.
×

Omitting `event.preventDefault()` in submit handler

Symptom
Form performs a full page reload on submit, all state is lost, network shows unwanted GET/POST request.
Fix
Write event.preventDefault() as the first line of every handleSubmit function.
×

Reading `event.target.value` inside async callback (setTimeout, fetch)

Symptom
event.target.value returns null or empty string because React recycles synthetic events.
Fix
Destructure the value immediately: const { value } = event.target; then use value in async code.
×

Using array index as key for dynamic field lists

Symptom
Adding or removing items causes incorrect state retention — inputs display stale values.
Fix
Use a unique identifier (UUID, timestamp, or database ID) as the key for each dynamically rendered item.
×

Not using `name` attribute in generic handler pattern

Symptom
All fields update the same state key or no field updates correctly.
Fix
Every input must have a name attribute that matches the key in your form state object.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between a controlled and an uncontrolled componen...
Q02SENIOR
If a user types into a controlled input but the displayed value doesn't ...
Q03SENIOR
Why can't file inputs be controlled components in React, and what patter...
Q01 of 03JUNIOR

What is the difference between a controlled and an uncontrolled component in React, and how do you decide which one to use in a given situation?

ANSWER
A controlled component has its value managed by React state: the value prop is tied to state, and an onChange handler updates that state. The DOM is just a display layer. An uncontrolled component stores its own value in the DOM, and you read it using a ref when needed. When to choose: Use controlled for most inputs — you need live state for validation, conditional rendering, or formatting. Use uncontrolled for file inputs (cannot be controlled by design) and when integrating with third-party libraries that manage their own DOM (rich text editors, date pickers). Also, uncontrolled is simpler if you only need the value once, on submit. The rule of thumb: if you need the value continuously, go controlled. If you only need it at a specific moment, uncontrolled is fine.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is a controlled component in React?
02
Why does React warn 'changing an uncontrolled input to controlled'?
03
Do I need a form library like React Hook Form if I understand controlled components?
04
Can I use controlled components with third-party UI libraries like Material-UI or Ant Design?
05
How do I handle resetting a controlled form?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's React.js. Mark it forged?

13 min read · try the examples if you haven't

Previous
React Router
8 / 47 · React.js
Next
React Context API