Initializing Enclave...

How to Fix React Warning: A Component is Changing an Uncontrolled Input to Be Controlled

Bug Severity: HIGH | UX/User Impact: MODERATE — form state desync, stale submissions, broken reset logic | Time to Fix: 5 mins

TL;DR

  • What broke: An <input> received value={undefined} on first render, making React treat it as uncontrolled, then a subsequent state update passed a real string — React can't switch modes mid-lifecycle.
  • How to fix it: Initialize your state as an empty string (''), not undefined or omitting the key entirely. Apply the value ?? '' nullish coalescing fallback as a defensive pattern.
  • Fastest path: Use our Client-Side Sandbox above to paste your component and auto-refactor all offending inputs in one shot.

The Incident (What Does the Error Mean?)

Raw browser console output:

Warning: A component is changing an uncontrolled input to be controlled.
This is likely caused by the value changing from undefined to a defined value,
which should not happen. Decide between using a controlled or uncontrolled
input element for the lifetime of the component.
    at input
    at ProfileForm

React enforces a hard rule: an input is either controlled (value prop owned by state) or uncontrolled (DOM owns it via defaultValue or no prop) — never both, never switching. When value={undefined} is passed, React silently treats the input as uncontrolled. The moment your async data loads and you pass value="[email protected]", React detects the mode switch and fires this warning. From this point, form state is unreliable — submitted values may be stale, onChange handlers may not fire correctly, and reset() calls produce inconsistent results.


The Blast Radius

This isn't cosmetic. The practical failures cascade fast:

  • Stale form submissions: User edits a field, but the submitted value is the pre-switch DOM value, not React state.
  • Broken reset logic: Calling setState('') after submit doesn't clear the input if React lost controlled ownership.
  • Silent data loss in multi-step forms: Inputs that were uncontrolled during step 1 carry forward DOM-owned values that never hit your state tree.
  • Test suite failures: userEvent.type() and fireEvent.change() in RTL behave differently on uncontrolled inputs, causing flaky tests that pass locally and fail in CI.

The warning is React telling you your form's source of truth is split between the DOM and your state object. In any form handling real user data — auth, payments, profile updates — this is a silent corruption vector.


How to Fix It

Root Cause Pattern

Almost always one of three things:

  1. State initialized as undefined (object destructuring miss, uninitiated useState())
  2. Data from an API loaded asynchronously, and the input renders before the fetch resolves
  3. Optional chaining returning undefined: value={user?.email}

Basic Fix — Guaranteed Defined Initial State

- const [formData, setFormData] = useState({});
+ const [formData, setFormData] = useState({ name: '', email: '', phone: '' });

  return (
-   <input value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} />
+   <input value={formData.email ?? ''} onChange={e => setFormData({...formData, email: e.target.value})} />
  );

The ?? '' is your last-line-of-defense fallback. It costs nothing and prevents the warning even if upstream data is malformed.


Enterprise Best Practice — Async Data + React Hook Form

When the form is populated from an API (useEffect + fetch), the race condition is structural. The correct pattern is defaultValues with reset() after data loads, not binding live API state directly to value.

- const { register } = useForm();
- const [user, setUser] = useState();
-
- useEffect(() => {
-   fetchUser().then(data => setUser(data));
- }, []);
-
- return <input {...register('email')} value={user?.email} />;

+ const { register, reset } = useForm({
+   defaultValues: { email: '', name: '', phone: '' }
+ });
+
+ useEffect(() => {
+   fetchUser().then(data => {
+     reset({
+       email: data.email ?? '',
+       name: data.name ?? '',
+       phone: data.phone ?? ''
+     });
+   });
+ }, [reset]);
+
+ // No value prop needed — RHF owns the input state
+ return <input {...register('email')} />;

Why this is correct: reset() reinitializes the entire form with defined values after the async operation completes. The input is controlled from frame one with '', then updated atomically. No mode switching, no undefined window.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. ESLint react/no-uncontrolled-input-adjacent rules

Install eslint-plugin-react and enforce:

{
  "rules": {
    "react/no-access-state-in-setstate": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

Pair with a custom ESLint rule or eslint-plugin-jsx-a11y to flag value={undefined} patterns at lint time, before the PR merges.

2. TypeScript — make undefined inputs a compile error

- interface FormState {
-   email?: string;
- }
+ interface FormState {
+   email: string; // Non-optional. Enforces defined initial value at the type level.
+ }

With strict TypeScript, useState<FormState>({ email: undefined }) becomes a compile-time error, not a runtime warning.

3. Pre-commit hook with tsc --noEmit

# .husky/pre-commit
npx tsc --noEmit && npx eslint src --ext .tsx,.ts

This gates every commit. The undefined-value pattern doesn't survive strict: true in tsconfig.json if your form state interfaces are properly typed.

4. Cypress / Playwright smoke test on form reset

Add a test that explicitly checks the input value immediately on component mount (before any async data resolves). If it's ever undefined, the test fails in CI before it reaches production.

it('input has defined value on initial render before data loads', () => {
  cy.intercept('/api/user', { delay: 2000, body: {} });
  cy.visit('/profile');
  cy.get('input[name="email"]').should('have.value', '');
});

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →