Controlled vs Uncontrolled Inputs
Either your framework owns every keystroke or the DOM does and you read at submit. A real trade-off between per-keystroke render cost and reactive capability — not a rule.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Should the current value of this field live in framework state, or in the DOM until I need it?
Someone types into a field and expects the characters to appear as fast as they type them — with a live character count, a formatted phone number or a filtered list keeping up if the design promised one.
Put every field in state. value={x} and onChange={e => setX(e.target.value)} on every input; that is the documented way, it makes the value available everywhere, and it is consistent across the form.
Every keystroke becomes a state update and a render of whatever subtree owns that state. On a form that keeps all fields in one object at the top, that is the whole form re-rendering per character (What a Component Costs to Render).
- Every keystroke becomes a state update and a render of whatever subtree owns that state. On a form that keeps all fields in one object at the top, that is the whole form re-rendering per character (What a Component Costs to Render).
- If an expensive sibling shares the state owner — a large table, a chart, a map — it re-renders per character too, and the caret visibly lags behind the typing on a mid-range phone (Interaction Responsiveness).
- Autofill writes several values at once without keystrokes. Controlled inputs whose state did not update can visibly snap back to the old value on the next render (Input Types, Inputmode and Autocomplete).
- A slow render between keypress and paint can reorder characters or drop the caret to the end of the field, because the value written back is stale relative to what the user has typed since.
- IME composition — Japanese, Chinese, Korean input — passes through intermediate states that a controlled input can normalise or clear mid-composition, making the field unusable in those languages.
- The opposite dogma breaks too: an uncontrolled form cannot show a live character counter, cannot disable submit as validity changes, and cannot filter a list as you type, because nothing is watching the value.
What is actually happening
In the browser, not in the framework.
- An
inputelement always has a value in the DOM. The question is only whether the framework asserts authority over it. - Controlled means the rendered
valueprop is the state, and every keystroke fires a change handler that sets state, which re-renders, which writes the value back. The DOM value is a projection of state, and a render that does not update it will overwrite what the user typed. - Uncontrolled means the element has a
defaultValueand thereafter owns its own value. You read it when you need it — via a ref, or vianew FormData(form)at submit — and the framework does not re-render on typing at all. - The
inputevent fires per character;changefires when the value is committed (blur for text, immediately for checkboxes and selects). Frameworks differ in which one they surface under a name likeonChange, which is a real source of confusion when moving between them. - The terms are React's. Other frameworks make different structural choices: Vue's
v-modelis two-way binding compiled to a value binding plus an event handler, but its reactivity is fine-grained so only the dependents of that value update. Svelte compilesbind:valueto direct DOM assignments with no virtual DOM diff. Solid's signals update exactly the DOM nodes that read them, so the component function does not re-run. Angular'sngModel/ reactive forms integrate with change detection, andOnPushplus signals narrows what is checked. - The cost that varies is what re-runs per keystroke, and that is a property of the reactivity model, not of the word "controlled" (Reactivity Models).
What this makes the browser do
And which of it is avoidable.
- The browser's own work per keystroke is the same either way: dispatch the event, update the DOM value, update the caret, style and paint the changed text.
- Controlled adds application work between the event and the frame — the handler, the state update, the reconciliation, the DOM write-back — all on the main thread, all inside the interaction that the user is timing (What the Main Thread Owns).
- Re-rendering a subtree does not necessarily touch the DOM. A virtual-DOM framework may diff a thousand elements and mutate none, which costs JavaScript time but no layout or paint (Reconciliation and Keys).
- Uncontrolled adds essentially nothing per keystroke, and moves the work to submit, where a single
FormDataconstruction reads the whole form at once. - Where controlled costs real rendering work rather than only script time is when the state drives something visible — a filtered list, a validity style, a progress bar — because that is a genuine DOM change per character.
The question is what reacts, not which is correct
Framed as "which is better", this has no answer. Framed as "what has to react to this value while it is being typed", it usually answers itself in one sentence per field.
Notice that the decision is per field, not per form. Treating it as a form-wide policy is what produces both of the failure shapes: a fully controlled form that re-renders a page per character, and a fully uncontrolled form with a bolted-on subscription for the one field that needed to be live.
What needs to observe this value between keystrokes?
when Nothing observes it while typing. Addresses, names, notes, most of a profile or checkout form.
cost No live derived state. Adding one later means changing the field's model, and testing reads DOM values rather than application state.
when Something local reacts per character: a counter, a strength meter, an inline format hint.
cost A render per keystroke of that field's component. Cheap if the component is small, and it stops being cheap silently as the component grows.
when Cross-field rules or dependent fields need the whole picture as it changes.
cost Every keystroke renders every field unless memoised, and memoisation is its own maintenance burden (Memoization).
when The value drives a request or an expensive computation — search-as-you-type, live preview.
cost Two values in flight, the displayed one and the settled one, and every consumer must be clear about which it reads (Derived State).
when One field in a large uncontrolled form needs live behaviour.
cost A second mechanism in the same form; readers have to know which fields are wired and which are not.
What a keystroke actually costs
The per-keystroke cost table below separates the browser's work, which is fixed, from the application's work, which is the part you chose. The distinction matters because "controlled inputs cause layout" is false, while "controlled state driving a filtered list causes layout" is true and is a different decision.
Read maybe as genuinely conditional rather than evasive: whether a render reaches the DOM at all depends on what changed, and whether a DOM change reaches layout depends on which properties it touched.
| Change | style | layout | paint | composite | Why |
|---|---|---|---|---|---|
| Typing in an uncontrolled input | yes | maybe | yes | yes | The browser updates the value and repaints the text. Layout only if the field grows — an auto-sizing textarea, for instance. No application code runs. |
| Controlled input, state scoped to the field | yes | maybe | yes | yes | Same browser work plus a small render. The added cost is JavaScript time inside the interaction, not extra pipeline stages. |
| Controlled input, state at the top of a large form | yes | maybe | yes | yes | Still the same pipeline stages, but the render diffs the entire form per character. The symptom is a delayed frame, not extra layout. |
| Controlled state driving a filtered list | yes | yes | yes | yes | Now the DOM genuinely changes: rows are added and removed, so layout and paint are unavoidable. This is where debouncing the derived work pays (List Virtualization). |
| Validity class toggling on `:user-invalid` | yes | maybe | yes | no | A colour or border change repaints; layout only if the error message changes the element's box. |
| Error message appearing below the field | yes | yes | yes | yes | Inserting a node reflows what follows it. Reserving the space up front avoids the content shift (Visual Stability). |
caveat Every row assumes the field is in normal flow with nothing unusual around it. A fixed-position sibling, a container query, or a parent with layout containment can change layout, paint and composite answers (CSS Containment).
The same choice in five frameworks
The trade-off exists in every framework, but the per-keystroke cost differs because the reactivity models differ. That is why "controlled inputs are expensive" is advice with a framework attached, and why porting a rule between stacks produces confident, wrong conclusions.
This table is not a ranking. Each row describes a different set of trade-offs that the framework made deliberately, and each choice buys something elsewhere — compile-time work, runtime flexibility, or a simpler mental model (Choosing a Framework).
- In every one of them,
FormDataat submit still works, because the DOM still holds the values. - In every one of them, autofill writes values without keystrokes, so value-change events are the reliable hook.
- The word "controlled" only maps cleanly onto React. Elsewhere, ask "what re-runs when this value changes" and answer that instead.
| Framework | The binding | What re-runs per keystroke | Where it bites |
|---|---|---|---|
| React | value + onChange, or defaultValue + ref | The owning component function and its subtree, then a diff | State scoped too high; the whole form re-renders per character (The React Mental Model) |
| Vue | v-model, compiled to a binding plus a handler | Only the reactive effects that depend on that ref | Deep reactive objects making unexpected dependents update (The Vue Mental Model) |
| Svelte | bind:value, compiled to direct DOM assignment | The generated update for that binding only | Two-way binding making the data flow direction hard to follow (The Svelte Mental Model) |
| Solid | Signal read in the JSX; the component runs once | Only the DOM expressions that read the signal | Destructuring a signal, which breaks tracking silently (The Solid Mental Model) |
| Angular | Reactive forms, or ngModel | Change detection over the component tree unless narrowed | Default change detection checking far more than changed (The Angular Mental Model) |
How to build it
Most important first.
- Ask what needs to react to the value as it changes. Nothing → uncontrolled. A live counter, live filtering, live validity, dependent fields, or formatting-as-you-type → controlled, scoped tightly.
- Scope the state to the smallest component that needs it. A single
formStateobject at the top of a large page is the main reason controlled inputs get a reputation for being slow; per-field state is usually enough. - For search-as-you-type, keep the input controlled and locally responsive, then debounce the derived work — do not debounce the value the input displays, or typing itself becomes laggy (Five Components, One Request).
- Prefer
FormDataat submit for large, plain forms. It reads every named control at once, works with file inputs, and needs no per-field wiring (Submission: Method, Encoding and Doing It Once). - Keep
nameattributes even on controlled inputs, soFormDataand autofill keep working and a submit path remains available that does not depend on state being correct. - Handle autofill and paste by reading value-change events rather than keyboard events, in either mode.
- For IME languages, either leave the field uncontrolled or avoid transforming the value during composition;
compositionstart/compositionendbracket the period where intermediate values must be left alone. - Mixed forms are fine and common: a controlled password field with a live strength meter next to five uncontrolled address fields is the correct design, not an inconsistency to clean up.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A controlled input that lags is an accessibility problem before it is a performance problem: input latency disproportionately affects users with motor impairments, users of switch or dictation input, and anyone on an older device.
- Do not re-render in a way that recreates the input element. Focus and caret position are lost, and a screen reader treats it as a new control and re-announces it (Focus Management).
- Live feedback driven by controlled state — character counters, validity hints — must be associated with the field via
aria-describedbyand updated politely, not announced on every character (Live Regions and Announcement). - Value transformations that fight the user, such as trimming or reformatting mid-entry, are worse with a screen reader, where the user cannot see the correction and hears an unexpected value read back.
- IME composition handling is an accessibility and internationalisation requirement, not an optimisation: a form that mangles Japanese input excludes those users entirely.
What can go wrong
- A controlled input with a
valuebut no change handler. It becomes read-only — the user types and nothing appears — and it is the single most common first bug in this area. - Switching an input between controlled and uncontrolled mid-life, usually by initialising state to
undefinedand then setting it. React warns; other frameworks may not, and the field loses its value. - Formatting on every keystroke — inserting spaces into a card number, currency separators into an amount — which moves the caret to the end of the field on every character. Correct caret restoration after reformatting is genuinely difficult.
- Debouncing the state that feeds the input's own
value. The field now updates a beat behind the user, which feels broken in a way that is hard to describe in a bug report. - Reading a ref's value in an effect that runs before the browser has committed autofill, getting an empty string, and concluding the form is empty.
- Uncontrolled fields inside a list that is reordered or re-keyed. The DOM values follow the nodes, so a bad key makes the values appear to swap rows (Reconciliation and Keys).
- A server response overwriting a field the user is currently editing, because the update path treats server data as authoritative without checking focus (Server State Is Not Your State).
- A keystroke arriving while a render triggered by the previous keystroke is still in flight, so the value written back is one character behind what the user has typed.
- Autofill setting several values in one turn while a re-render is pending, so the render overwrites the filled values with stale state.
- A background refetch resolving with server data while the user is mid-edit, overwriting the field under the caret (Stale-While-Revalidate).
- IME composition events interleaving with controlled value updates, so a normalising transform runs against a partial composition.
- Neither mode changes what the server may believe. The DOM value, the state value and the request body are all under the user's control (What the Frontend Is Responsible For in Auth).
- Controlled state keeps values in framework memory, where they may be captured by error reporters, dev tools extensions and state-snapshot logging. Passwords and card data in a serialised state tree are a real exfiltration path (Session Replay and the Privacy It Costs).
- Uncontrolled values live only in the DOM, which is not automatically safer — session replay and third-party scripts read the DOM too — but does keep them out of application state snapshots by default.
- Rendering a submitted value back into the page is the same XSS question in both modes; the framework's escaping is what protects you, and
dangerouslySetInnerHTMLor its equivalents remove that protection (Cross-Site Scripting).
- "Controlled is the correct way; uncontrolled is legacy." Both are supported, documented and appropriate. Uncontrolled is what the platform does; controlled is what you opt into when you need reactivity.
- "Controlled inputs are slow." Controlled inputs are cheap. Re-rendering an expensive subtree per keystroke is slow, and that is a state-scope decision you made separately.
- "Uncontrolled means no validation." Native constraint validation works perfectly on uncontrolled inputs, and
checkValidity()is available at any moment (Native Validation and Its Limits). - "This distinction is universal." The words are React's. Vue, Svelte, Solid and Angular all bind values, but what re-runs per keystroke differs enough that advice does not port unchanged (Reactivity Models).
- "Two-way binding is controlled."
v-modelandngModellook like two-way binding and compile to a one-way binding plus a handler. The syntax hides the direction; it does not remove it. - "Refs are an escape hatch to avoid." Reading a DOM value at submit is the platform's own model, not a workaround.
Measuring it, and what changes in the field
- The Performance panel's interaction records: type into the field, look at the main-thread work between
keydownand the next frame. If it is dominated by your framework's render, the state is scoped too widely (Interaction Responsiveness). - The framework's own profiler — React DevTools' Profiler, Vue DevTools, Angular's change-detection profiling — to see which components re-rendered per keystroke and why.
- CPU throttling in devtools. Per-keystroke render cost is exactly the sort of thing that is invisible at full speed and obvious at 4–6x slowdown (Measure Before Optimising).
- Field-level input latency from real users, which is the only way to know whether the median device is keeping up (Real User Monitoring).
- On a fast desktop, a controlled form of any realistic size feels fine. The decision only becomes visible on a mid-range phone or with an expensive sibling in the same render scope.
- Form size matters less than render scope: 40 fields with per-field state can be cheaper per keystroke than 6 fields sharing one object at the top of the page.
- Long, mostly-static forms — profiles, settings, checkouts — favour uncontrolled. Highly reactive forms — filters, builders, anything with dependent fields — favour controlled.
- On a slow network, server-driven updates arrive while the user is typing, and a controlled field that accepts them unconditionally overwrites in-progress edits (State Synchronization).
- Under a fine-grained reactivity model, the per-keystroke cost of a bound value is close to the uncontrolled cost, which genuinely changes where the trade-off sits.
- Uncontrolled forms give up live derived state. Anything that must react per character has to be added back, usually as a targeted subscription that reintroduces some of the cost.
- Controlled forms give up nothing in capability and pay per keystroke. Scoping state narrowly recovers most of the cost, at the price of more components and more plumbing.
- Mixed forms are the pragmatic answer and are harder to reason about, because "where does this value live" is now a per-field question a reader has to check.
FormDataat submit gives you strings and files with no types. Parsing and coercion move to one place — which is arguably where they belonged — but they do not disappear (Parse, Do Not Validate).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- FRAMEWORK-SPECIFICThe words "controlled" and "uncontrolled" are React vocabulary. Vue expresses the same choice as
v-modelversus arefon the element; Svelte asbind:valueversus reading the node; Solid as a signal-boundvalueversus an uncontrolledinput; Angular as reactive forms versus template refs. The choice exists everywhere; the cost per keystroke differs because the reactivity models differ. - DEVICE-SPECIFICPer-keystroke render cost is invisible on a modern laptop and clearly visible on a mid-range phone, where the same JavaScript takes several times longer; any conclusion drawn on a development machine understates the gap.
- GENERALThe underlying platform behaviour — the element owns a value,
inputfires per character,changefires on commit,FormDatareads named controls — is identical in every framework and in none.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — the per-keystroke cost of a reactive binding is ultimately a question about closures, dependency tracking and allocation in the JavaScript engine underneath the framework.