Error: A component is changing an uncontrolled input of type ... to be controlled. - 10 Causes and Fixes
React just logged Error: A component is changing an uncontrolled input of type ... to be controlled. in your console, and the input you were typing into probably still works - which is why this one is easy to ignore for weeks. It is not a crash; it is React telling you that a single <input> was mounted without a value prop (or with value={undefined}) and then re-rendered with one.
That switch matters because React manages the two kinds of inputs differently. An uncontrolled input keeps its text in the DOM node. A controlled input keeps it in your state and forces the DOM to match. Flipping between the two mid-life means React has to decide whose copy of the text wins, and the answer is not always the one you wanted - cursor jumps, wiped characters, and "my form resets when data loads" bugs all trace back here.
This page covers the ten concrete ways a value prop goes from undefined to a string, each with a reproduction you can paste into a component, the lines that fix it, and a way to verify. If none of them match your case, the diagnostic checklist narrows it down in five steps.
| Error | Warning: A component is changing an uncontrolled input of type text to be controlled. Input elements should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled input element for the lifetime of the component. |
|---|---|
| Where it happens | React (react-dom and react-native), development builds only - any framework on top of React: Next.js, Vite, Create React App, Remix. Fires from the form layer, usually on the render after an async fetch or a state reset. |
| What it means | One , or rendered with value={undefined} on one render and value="something" on the next, so React cannot tell whether your state or the DOM owns the text. |
The Fast Fix
Make sure the value prop is never undefined or null. Give the state an empty-string default and coalesce at the JSX boundary:
const [email, setEmail] = useState(""); // not useState() and not useState(null)
<input
type="text"
value={email ?? ""} // the fix: undefined/null becomes ""
onChange={(e) => setEmail(e.target.value)}
/>
The ?? "" makes the input controlled from the very first render, so there is no switch to warn about. Keep the onChange - a value with no onChange makes the field read-only and produces a different React warning.
If you genuinely want an uncontrolled input, pass defaultValue instead of value and read it with a ref. Pick one per field and keep it for the component's lifetime.
What Is Actually Causing It
Jump to your case
- 1. State initialized with no argument, so value starts undefined
- 2. Value comes from an async fetch that has not resolved yet
- 3. API field is missing or explicitly null in the response
- 4. Reset sets state back to undefined or null instead of empty string
- 5. Form state object is missing the key for that field
- 6. Optional value prop forwarded from a wrapper component
- 7. Both value and defaultValue passed, with value undefined at first
- 8. Select or textarea with a value that does not match any option
- 9. Checkbox or radio using value instead of checked
- 10. Conditional value expression that returns undefined on one branch
1. State initialized with no argument, so value starts undefined
Reproduce it
function NameField() {
const [name, setName] = useState(); // no initial value -> undefined
return (
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
);
}
Type one character and the warning fires.
Why it happens — useState() with no argument returns undefined. React treats value={undefined} as "no value prop at all", so the first render produces an uncontrolled input. The first keystroke sets state to a string, and the second render arrives with a real value - the switch React is complaining about.
The fix
function NameField() {
const [name, setName] = useState(""); // empty string, not undefined
return (
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
);
}
Changed line: useState() became useState(""), so value is a string on every render including the first.
Confirm it worked — Add console.log(typeof name) in the component body. It must print string on the very first render, before you touch the field.
2. Value comes from an async fetch that has not resolved yet
Reproduce it
function ProfileForm() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch("/api/me")
.then((r) => r.json())
.then(setUser);
}, []);
// first render: user is null -> user?.email is undefined
return <input type="text" value={user?.email} onChange={() => {}} />;
}
Warning appears the moment the fetch resolves.
Why it happens — The first render happens before the response arrives, so user?.email evaluates to undefined and the input mounts uncontrolled. When setUser lands, value becomes a string. This is the single most common path to this warning in real apps.
The fix
function ProfileForm() {
const [email, setEmail] = useState(""); // controlled from render one
useEffect(() => {
fetch("/api/me")
.then((r) => r.json())
.then((user) => setEmail(user.email ?? ""));
}, []);
return (
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
);
}
Changed lines: state holds the field value itself with an "" default, and the fetch result is coalesced with ?? "" before it reaches state.
If you must keep the whole object in state, render value={user?.email ?? ""} instead - or return a loading placeholder until user exists, so the input never mounts in the undefined phase.
Confirm it worked — Throttle the network tab to Slow 3G and reload. The field should render empty with no console warning, then fill in when the request completes.
3. API field is missing or explicitly null in the response
Reproduce it
// GET /api/users/7 -> { "id": 7, "name": "Ada", "phone": null }
<input type="tel" value={user.phone} onChange={handlePhone} />
No warning for name, warning for phone - and only for users whose phone is null.
Why it happens — React only skips the value prop when it is undefined or null. A nullable column in your database becomes null in JSON, which mounts the input uncontrolled; the first keystroke makes it controlled. The bug is data-dependent, so it passes in testing with complete records.
The fix
<input type="tel" value={user.phone ?? ""} onChange={handlePhone} />
Changed line: ?? "" at the JSX boundary. Better still, normalize once at the fetch boundary so every consumer gets strings:
function normalizeUser(raw) {
return { ...raw, phone: raw.phone ?? "", name: raw.name ?? "" };
}
Do not use || if 0 or false is a legal value for the field - value={count || ""} turns a real zero into an empty box. ?? only replaces null and undefined.
Confirm it worked — Load a record you know has null in that column. The console should stay clean and the field should render as empty rather than blank-but-uncontrolled.
4. Reset sets state back to undefined or null instead of empty string
Reproduce it
const [query, setQuery] = useState("");
function clear() {
setQuery(undefined); // or setQuery(null)
}
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} />
<button onClick={clear}>Clear</button>
This fires the reverse warning first (controlled to uncontrolled), then this one when you type again.
Why it happens — The input starts controlled correctly, but the reset handler nulls out the value, so React drops the value prop and the field becomes uncontrolled. Typing afterwards makes it controlled again. You get both directions of the warning from one button.
The fix
function clear() {
setQuery(""); // empty string keeps the input controlled
}
Changed line: the reset value is "" rather than undefined. Apply the same rule to whole-form resets - spread a constant of empty strings, never setForm({}).
Confirm it worked — Type, click Clear, then type again. Console stays clean and the field clears on the first click.
5. Form state object is missing the key for that field
Reproduce it
const [form, setForm] = useState({ email: "" }); // no 'company' key
<input
name="company"
value={form.company} // undefined until first keystroke
onChange={(e) => setForm({ ...form, [e.target.name]: e.target.value })}
/>
The email field is fine; the company field warns.
Why it happens — Reading a key that was never declared on the state object returns undefined. The generic onChange then creates the key, so the next render has a string. Adding a field to the JSX without adding it to the initial state is the usual trigger.
The fix
const INITIAL_FORM = { email: "", company: "" }; // every rendered field declared
const [form, setForm] = useState(INITIAL_FORM);
<input
name="company"
value={form.company}
onChange={(e) => setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }))}
/>
Changed lines: company: "" added to the initial state, and the updater is now functional so batched changes do not drop keys. Keeping INITIAL_FORM as a module constant also gives you a safe reset target.
Confirm it worked — console.log(Object.keys(form)) on first render and compare against the name attributes in your JSX. Every input name must be present.
6. Optional value prop forwarded from a wrapper component
Reproduce it
function TextField({ label, value, onChange }) {
return (
<label>
{label}
<input type="text" value={value} onChange={onChange} />
</label>
);
}
// caller forgets value on one instance
<TextField label="Nickname" onChange={handleNickname} />
Only the instance without value warns.
Why it happens — A destructured prop that the caller omitted is undefined, and the wrapper passes it straight through to the DOM input. The warning points at your design-system component, not at the caller who actually caused it - which is why these take so long to locate.
The fix
function TextField({ label, value = "", onChange }) { // default at the boundary
return (
<label>
{label}
<input type="text" value={value} onChange={onChange} />
</label>
);
}
Changed line: value = "" as a default parameter. Note that a default parameter only covers undefined - if callers can pass null, use value={value ?? ""} inside the JSX instead.
If the wrapper must support both modes, accept defaultValue as a separate prop and forward exactly one of the two:
function TextField({ value, defaultValue, onChange, ...rest }) {
const controlled = value !== undefined;
return controlled
? <input {...rest} value={value} onChange={onChange} />
: <input {...rest} defaultValue={defaultValue} onChange={onChange} />;
}
Confirm it worked — Temporarily add if (value === undefined) console.warn("uncontrolled", label) inside the wrapper. The log names the offending instance.
More causes (4 remaining)
7. Both value and defaultValue passed, with value undefined at first
Reproduce it
function CityField({ city }) { // city arrives one render late
return <input type="text" defaultValue="Seoul" value={city} onChange={() => {}} />;
}
React mounts it uncontrolled with "Seoul", then switches when city resolves.
Why it happens — defaultValue sets the DOM node's initial text and declares the input uncontrolled; value declares it controlled. When value is undefined on mount, defaultValue wins, and the later string flips the field to controlled - plus React warns separately that you provided both.
The fix
function CityField({ city }) {
return (
<input
type="text"
value={city ?? "Seoul"} // one prop, with the default folded into it
onChange={() => {}}
/>
);
}
Changed lines: defaultValue removed and the fallback moved inside value. Never put both props on the same element.
Confirm it worked — Search the component for defaultValue. On any element that also has value, one of the two must go.
8. Select or textarea with a value that does not match any option
Reproduce it
const [role, setRole] = useState(); // undefined
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
Same warning text, with <select> named instead of an input type.
Why it happens — <select> and <textarea> follow the same controlled/uncontrolled rule as <input>, and React emits the matching warning for each. An undefined value also means the browser silently selects the first option, so your state and the visible selection disagree until the user touches it.
The fix
const [role, setRole] = useState(""); // controlled from the start
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="">Select a role</option> {/* placeholder matches "" */}
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
Changed lines: useState("") plus an option whose value is "", so the controlled empty state has something legal to display. For <textarea>, pass the text through value - never as children.
Confirm it worked — On first render, the placeholder option is selected and role === "". Submitting without choosing should fail your validation rather than silently sending admin.
9. Checkbox or radio using value instead of checked
Reproduce it
const [agreed, setAgreed] = useState(false);
<input
type="checkbox"
checked={undefined} // or omitted entirely on first render
value={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
Warning text names of type checkbox.
Why it happens — For checkboxes and radios, the controlling prop is checked, not value - value is just the string submitted with the form. A checked that is undefined on mount and boolean later is the same uncontrolled-to-controlled switch, and checked={someUndefinedState} is the usual source.
The fix
const [agreed, setAgreed] = useState(false); // boolean default, never undefined
<input
type="checkbox"
checked={agreed} // the controlling prop
value="terms" // what gets submitted, a static string
onChange={(e) => setAgreed(e.target.checked)}
/>
Changed lines: checked now reads the boolean state, value is a plain string, and the handler reads e.target.checked rather than e.target.value. If the flag can arrive from an API as null, write checked={!!agreed}.
Confirm it worked — console.log(typeof agreed) must print boolean on the first render. Click the box twice and confirm the state flips both ways.
10. Conditional value expression that returns undefined on one branch
Reproduce it
<input
type="text"
value={isEditing ? draft : undefined} // read-only branch drops the prop
onChange={(e) => setDraft(e.target.value)}
readOnly={!isEditing}
/>
Toggling edit mode fires the warning in both directions.
Why it happens — Any ternary or && in the value position that can evaluate to undefined toggles the input between controlled and uncontrolled as the condition changes. value={user && user.email} has the same problem: when user is null, the expression is null, so the prop is dropped.
The fix
<input
type="text"
value={isEditing ? draft : savedValue} // both branches return a string
onChange={(e) => setDraft(e.target.value)}
readOnly={!isEditing}
/>
Changed line: the false branch returns the saved string instead of undefined. Use readOnly or disabled to express "not editable" - never the absence of value.
For the && variant, switch to optional chaining plus a fallback: value={user?.email ?? ""}.
Confirm it worked — Toggle edit mode back and forth several times with the console open. No warning in either direction, and the text never disappears.
## None of Those? Narrow It Down
- Read the warning text for the input type it names -
of type text,of type checkbox,of type select-one. That tells you whether to auditvalueprops orcheckedprops, and narrows the search to the fields of that type. - Install a React error overlay or open the console stack trace and click through to the component frame. React names the component that rendered the element, so expand the trace before you start grepping - it usually lands you on a shared wrapper, which points at cause 6.
- In the component body, log the controlling prop's type on every render:
console.log('render', typeof value). If the first line isundefinedand a later one isstring, you have confirmed the switch and know which render changed it. If every line saysstring, the warning is coming from a different element than you think. - Check whether the transition lines up with an async boundary - a fetch resolving, a router param arriving, a context provider hydrating. If the warning fires exactly once right after data loads, it is cause 2 or 3; look at whether the specific field is nullable in the response.
- If it fires on a user action instead, identify the action. A Clear or Reset button points at cause 4; toggling an edit mode points at cause 10; a newly added field that warns while its siblings do not points at cause 5.
- Grep the file for
defaultValueand check for any element carrying bothdefaultValueandvalue- that combination is cause 7 and also emits its own separate React warning you may have scrolled past. - If the field is inside a form library (React Hook Form, Formik), confirm you are not mixing registration styles - a
register("field")spread plus your ownvalueprop, or a Formik<Field>with a manualvalue, gives two owners for one input. Use exactly one mechanism per field. - Still unmatched: temporarily hard-code
value=""on the suspect element. If the warning disappears, the problem is upstream of that prop and you can walk the expression outward; if it persists, you are looking at the wrong element and should go back to step 2.
Why This Error Exists At All
The DOM was designed with form state built in. A browser <input> node holds its own text in a mutable value property, updates it on every keystroke without asking anyone, and keeps it until the page unloads. That is the whole model: the node is the source of truth.
React's model is the opposite. Your state is the source of truth, and the DOM is a projection of it that React recomputes on every render. These two models cannot both own the text in the same box, so React makes you choose per element. Pass a value prop and React takes ownership: after every render it writes your string onto the node, which is why a controlled input with no onChange appears frozen - you are typing, and React is overwriting you. Omit value (or pass undefined/null, which React treats as omitted) and React steps back, leaving the browser in charge; defaultValue just seeds the node once and then stays out of the way.
The decision is made at mount, because it determines what React stores internally for that element and whether it reconciles the node's value at all. Changing it later leaves React with two conflicting copies of the text and no rule for which one wins - and the resolution is genuinely surprising: the DOM node often keeps characters your state never saw, or React's first controlled write wipes what the user already typed. Rather than silently pick a side, React warns and tells you to decide for the component's lifetime.
The family this belongs to is "React wants a stable shape across renders." The same instinct produces the hooks rules (never call a hook conditionally, because the hook list is positional), the key warning on lists (identity must be stable so children are matched correctly), and the mirrored controlled-to-uncontrolled warning. When React complains about something changing between renders, the fix is almost never to handle the change - it is to make the thing not change, usually by giving it a concrete default on render one instead of letting it start as undefined.
Stop It From Coming Back
- Type the props so
undefinedcannot reach the DOM. In TypeScript, declarevalue: string(notvalue?: string) on your field components, and model nullable API fields asstring | nullso the compiler forces a?? ""at the boundary where you read them. - Normalize at the fetch boundary, not in JSX. Run every API response through a parser - Zod's
z.string().nullish().transform((v) => v ?? ""), or a hand-writtennormalizeUser- so nulls become empty strings once instead of at twenty call sites. - Keep initial form state as a single exported constant (
const INITIAL_FORM = { email: "", company: "" }) and use it for bothuseState(INITIAL_FORM)and every reset. Adding a field to the constant and the JSX in the same commit removes cause 5 and cause 4 together. - Funnel all text inputs through one
<TextField>wrapper that appliesvalue={value ?? ""}and refuses to forwarddefaultValuealongsidevalue. One guarded component is cheaper to keep correct than every form in the app. - Turn the warning into a test failure. In your Jest or Vitest setup file, fail on React warnings:
jest.spyOn(console, "error").mockImplementation((msg) => { throw new Error(msg); }), so a render test on any form catches the switch in CI instead of in someone's console. - Enable
eslint-plugin-react's rules for form props (react/jsx-no-duplicate-propscatches a doubledvalue, andreact/require-default-propspushes defaults onto optional props) so the wrapper-component variant is caught at lint time.
Related Guides
-
TS2345: Argument of type is not assignable to parameter of type
-
SettingWithCopyWarning: A value is trying to be set on a copy of a slice
-
TypeError: Cannot read properties of undefined (reading 'map')
Errors You Will Probably Hit Next
Warning: A component is changing a controlled input to be uncontrolled. - the mirror image, usually a reset handler setting state back to undefined or null.Warning: You provided avalueprop to a form field without anonChangehandler. This will render a read-only field. - what you hit after addingvaluebut forgetting the handler.Warning: Each child in a list should have a unique "key" prop. - the same "React needs a stable shape across renders" family, applied to lists instead of form ownership.
Every instance of this warning is the same fact: a value or checked prop was undefined on one render and a real value on the next. So the question is never "how do I handle the switch" - it is "where does the first render get its undefined from," and the answer is almost always an uninitialized useState, an unresolved fetch, a nullable column, or an omitted prop on a wrapper. Coalesce with ?? at the boundary where the data enters, pick controlled or uncontrolled per field and keep it, and treat the warning as a data-shape bug rather than a React quirk.
댓글
댓글 쓰기