React Controlled Components — Checkbox Always True
Checkbox always stores 'on' due to event.target.value — the #1 React bug.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- React state owns the input's value — the DOM just displays it.
- Two requirements:
valueprop bound to state, andonChangehandler that updates state. - Single form state object with generic handler scales to any number of fields.
- Miss
onChangeand the input becomes read-only — React logs a warning in dev. - Common pitfall: starting state as
nullcauses React to treat it as uncontrolled 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.
defaultChecked instead of checked makes the checkbox uncontrolled after mount — React will not update it on re-render, causing stale UI.defaultChecked and never wired onChange.checked + onChange — never defaultChecked.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.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.
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.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.
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.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.
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.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.
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.value instead of checked silently corrupts data.event.target.checked for checkboxes; test with both ticked and unticked states in QA.event.target.checked — always a boolean.value prop driven by state.value prop, not children.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.
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.
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.
Here's how to handle file inputs correctly:
- Use
useRefto create a reference to the input element. - On form submit, read
ref.current.filesto get theFileListobject. - Validate file type and size before sending to the server.
- Use
FormDatato upload the file viafetchorXMLHttpRequest.
- Always specify
acceptattribute to guide the user (e.g.,accept="image/*"). - Allow multiple files if needed with the
multipleattribute. - 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.
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.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
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.
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.
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.
The Checkbox That Always Said Yes
true regardless of user action.event.target.value returns the checked state for checkboxes.event.target.value which always returns the string 'on' for checkboxes, instead of event.target.checked which returns the boolean.type and use checked:
``
const { name, value, type, checked } = event.target;
setFormValues(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
``- For checkboxes, always read
event.target.checkednotevent.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.
value prop tied to state AND an onChange handler that calls the state setter with the new value.'' instead of undefined or null.'on' or true regardless of tick.event.target.checked for checkboxes, not event.target.value.value prop on <select> matches the value of one of the <option> elements.event.preventDefault() as the first line in your submit handler.In browser console: `$r.state` or inspect the component state.Add a `console.log(event.target.value)` inside onChange to verify events fire.setState(event.target.value)Key takeaways
handleChange that reads event.target.name scales to any size formuseState per field.event.target.checked (boolean), not event.target.value (always the string 'on'). Mixing these up creates a bug that won't throw an erroruseRef and read .files on submit.Common mistakes to avoid
5 patternsInitializing form state as `null` or `undefined`
'' instead of null or undefined.Omitting `event.preventDefault()` in submit handler
event.preventDefault() as the first line of every handleSubmit function.Reading `event.target.value` inside async callback (setTimeout, fetch)
event.target.value returns null or empty string because React recycles synthetic events.const { value } = event.target; then use value in async code.Using array index as key for dynamic field lists
Not using `name` attribute in generic handler pattern
name attribute that matches the key in your form state object.Interview Questions on This Topic
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?
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.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's React.js. Mark it forged?
13 min read · try the examples if you haven't