Fix 'A component is changing an uncontrolled input to be controlled' in React

beginnerโš›๏ธ React2026-05-03| React 16+, React 17, React 18 โ€” any browser, any OS. Triggered in development mode when form state initializes as undefined or null.

Error Message

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).
#react#forms#controlled-components#state#input

The Error

You open the browser console and there it is:

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.

The form still works โ€” sort of. But this warning signals that your input is switching identity mid-render. That flip causes real bugs: stale values, broken validation, the cursor jumping to the end while you type. Don't ignore it.

Why This Happens

React draws a hard line between two types of inputs:

  • Uncontrolled: no value prop โ€” the DOM owns the state.
  • Controlled: value is tied to React state โ€” React is the source of truth.

The warning fires when an input starts life as uncontrolled (value={undefined}), then flips to controlled (value="something") after a state update. React cannot handle that mid-lifecycle identity crisis.

Nine times out of ten, the culprit is dead simple: state initialized as undefined or null instead of an empty string.

Step-by-Step Fix

Step 1 โ€” Track down the broken input

The warning usually names the component. If it doesn't, open React DevTools and hunt for inputs where value toggles between undefined and a real string across renders. Start with your state initialization โ€” that's where the problem lives 90% of the time.

Step 2 โ€” Initialize state with an empty string

This one change fixes the vast majority of cases. Here's the broken pattern:

// BROKEN โ€” value starts as undefined
const [name, setName] = useState();
const [email, setEmail] = useState(null);

return (
  <form>
    <input type="text" value={name} onChange={e => setName(e.target.value)} />
    <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
  </form>
);

And here's the fix:

// FIXED โ€” value starts as empty string
const [name, setName] = useState('');
const [email, setEmail] = useState('');

return (
  <form>
    <input type="text" value={name} onChange={e => setName(e.target.value)} />
    <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
  </form>
);

Step 3 โ€” Fix object-based form state

Many forms store all fields in a single state object. When you do that, every field needs an explicit string default โ€” missing fields silently become undefined:

// BROKEN โ€” form.email is undefined โ†’ uncontrolled on first render
const [form, setForm] = useState({ name: '' });

// FIXED โ€” every field declared upfront
const [form, setForm] = useState({
  name: '',
  email: '',
  phone: '',
  message: ''
});

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

Step 4 โ€” Defend against API data

Fetch user data to pre-fill a form and you hit a timing problem. While the request is in flight, user is null โ€” which means every input value is undefined on the first render:

// BROKEN โ€” user?.name is undefined while fetching
const [user, setUser] = useState(null);

useEffect(() => {
  fetchUser().then(data => setUser(data));
}, []);

return (
  <input type="text" value={user?.name} onChange={...} />
);

// FIXED โ€” nullish coalescing as a safety net
return (
  <input type="text" value={user?.name ?? ''} onChange={...} />
);

Better yet, initialize user with the full shape so there's nothing to fall back from:

const [user, setUser] = useState({ name: '', email: '', role: '' });

useEffect(() => {
  fetchUser().then(data => setUser(data));
}, []);

Step 5 โ€” Don't mix value and defaultValue

For an uncontrolled input that needs a starting value, use defaultValue. Using both value and defaultValue on the same input is a frequent mistake:

// Mixing controlled and uncontrolled โ€” pick one
<input value={name} defaultValue="John" onChange={...} />

// Controlled โ€” state drives everything
<input type="text" value={name} onChange={e => setName(e.target.value)} />

// Uncontrolled โ€” DOM manages value, read it via ref
<input type="text" defaultValue="John" ref={inputRef} />

Verify the Fix

  • Open Chrome DevTools โ†’ Console tab.
  • Reload and interact with the form: type, submit, clear a field.
  • The warning A component is changing an uncontrolled input should be gone.
  • In React DevTools, check the component โ€” the input's value prop should always be a string. Never undefined, never null.

Warning still showing? Cast a wider net. Search the entire component tree for <input>, <textarea>, and <select> elements โ€” any of them can trigger it.

Quick Checklist

  • useState() with no argument โ†’ change to useState('')
  • useState(null) for a text field โ†’ change to useState('')
  • Object state with missing fields โ†’ declare all fields with '' defaults
  • API data used directly as value โ†’ add ?? '' fallback
  • Both value and defaultValue on the same input โ†’ remove one
  • Checkbox or radio? Use checked with a false default, not undefined

One More Edge Case: Checkboxes

The same warning hits checkboxes when checked starts as undefined. Same fix, different default value:

// BROKEN
const [agreed, setAgreed] = useState(); // undefined
<input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)} />

// FIXED
const [agreed, setAgreed] = useState(false);
<input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)} />

The underlying principle: once you put a value or checked prop on an input, React owns it. That means React's state must be a valid value from the very first render โ€” an empty string for text fields, false for checkboxes. undefined and null hand control back to the DOM, which is exactly what starts the fight.

Related Error Notes