Type something to search...
Controlled vs Uncontrolled Components in React Forms

Controlled vs Uncontrolled Components in React Forms

Every form input in React has a value, and something has to own it. Either React holds the value in state and pushes it into the input on every render, or the browser's DOM holds it and React reads it when needed. That choice is the difference between controlled and uncontrolled components, and it affects how you validate, how you reset, how often your component re-renders, and which bugs you will run into.

You have probably seen the warning "A component is changing an uncontrolled input to be controlled" at some point. It is a sign that an input switched owners halfway through its life. Understanding the two models makes that warning, and a lot of form behavior, easy to reason about.

This post explains both approaches with working examples, compares their trade-offs, shows how React 19 form actions make uncontrolled inputs more attractive, and covers how to build your own components that support both modes.

Controlled Components

A controlled input gets its value from React state and reports changes through onChange. React is the single source of truth.

import { useState } from "react";

export function NameField() {
  const [name, setName] = useState("");

  return (
    <div>
      <label htmlFor="name">Name</label>
      <input
        id="name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <p>Hello, {name || "stranger"}!</p>
    </div>
  );
}

The cycle on each keystroke is:

  1. The user types a character.
  2. The browser fires an input event, which React exposes as onChange.
  3. Your handler calls setName with the new value.
  4. React re-renders and sets the input's value to the new state.

If your handler did not call setName, the input would refuse to change. That is the defining property: the input displays exactly what state says, nothing more.

That control is useful. You can transform input as the user types:

import { useState } from "react";

export function PhoneField() {
  const [digits, setDigits] = useState("");

  return (
    <input
      inputMode="numeric"
      aria-label="Phone number"
      value={digits}
      onChange={(e) => setDigits(e.target.value.replace(/\D/g, "").slice(0, 10))}
    />
  );
}

Non-digit characters never appear, and the length is capped at ten. With a controlled input you can also enable or disable buttons based on current values, show live character counts, and keep several inputs in sync.

Uncontrolled Components

An uncontrolled input keeps its value in the DOM, just like plain HTML. You give it an initial value with defaultValue and read the current value when you need it.

There are two common ways to read uncontrolled values. The first is a ref:

import { useRef, type FormEvent } from "react";

export function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
  const inputRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    onSearch(inputRef.current?.value ?? "");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue="" aria-label="Search" />
      <button type="submit">Search</button>
    </form>
  );
}

The second, and usually better, way is to read the whole form with FormData, which needs no refs at all:

import type { FormEvent } from "react";

interface Signup {
  email: string;
  plan: string;
  newsletter: boolean;
}

export function SignupForm({ onSubmit }: { onSubmit: (data: Signup) => void }) {
  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = new FormData(e.currentTarget);
    onSubmit({
      email: String(form.get("email") ?? ""),
      plan: String(form.get("plan") ?? "free"),
      newsletter: form.get("newsletter") === "on",
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="plan">Plan</label>
      <select id="plan" name="plan" defaultValue="free">
        <option value="free">Free</option>
        <option value="pro">Pro</option>
      </select>

      <label>
        <input type="checkbox" name="newsletter" defaultChecked />
        Send me product updates
      </label>

      <button type="submit">Sign up</button>
    </form>
  );
}

Note the attribute pairs:

InputControlledUncontrolled
Text, textarea, selectvaluedefaultValue
Checkbox, radiocheckeddefaultChecked

Uncontrolled inputs do not re-render your component on each keystroke because there is no state change. They also work with native browser validation like required, type="email", and pattern out of the box.

File Inputs Are Always Uncontrolled

An input with type="file" cannot be controlled, because browsers do not let scripts set its value for security reasons. You read the selected files from the event or a ref:

export function AvatarPicker({ onPick }: { onPick: (file: File) => void }) {
  return (
    <input
      type="file"
      accept="image/*"
      aria-label="Choose avatar"
      onChange={(e) => {
        const file = e.target.files?.[0];
        if (file) onPick(file);
      }}
    />
  );
}

Using onChange does not make an input controlled. What makes it controlled is passing value or checked. For a complete upload flow, see handling file uploads in React with drag and drop.

The Switching Warning

React warns when an input changes from uncontrolled to controlled, or the other way round. The most common cause is an initial value of undefined:

// Causes the warning
const [user, setUser] = useState<{ name?: string }>({});
<input value={user.name} onChange={(e) => setUser({ name: e.target.value })} />;

On the first render user.name is undefined, so React treats the input as uncontrolled. After the first keystroke it becomes a string, and the input switches to controlled. Fix it by always providing a defined value:

<input
  value={user.name ?? ""}
  onChange={(e) => setUser({ name: e.target.value })}
/>

A related mistake is passing value without onChange. React then renders a read-only input and warns. Either add onChange, use defaultValue if you meant it as an initial value, or add readOnly if it really should not change.

React 19 Form Actions and Uncontrolled Inputs

React 19 lets you pass a function to a form's action prop. React calls it with the form's FormData when the form is submitted, tracks the pending state, and, after a successful action, resets uncontrolled fields automatically. This makes uncontrolled inputs the natural fit for many forms.

import { useActionState } from "react";
import { useFormStatus } from "react-dom";

interface State {
  error: string | null;
  saved: string | null;
}

async function saveComment(_prev: State, formData: FormData): Promise<State> {
  const text = String(formData.get("comment") ?? "").trim();
  if (text.length < 3) {
    return { error: "Comment is too short", saved: null };
  }

  const res = await fetch("/api/comments", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text }),
  });
  if (!res.ok) return { error: "Could not save comment", saved: null };

  return { error: null, saved: text };
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Posting..." : "Post comment"}
    </button>
  );
}

export function CommentForm() {
  const [state, formAction] = useActionState(saveComment, {
    error: null,
    saved: null,
  });

  return (
    <form action={formAction}>
      <label htmlFor="comment">Comment</label>
      <textarea id="comment" name="comment" rows={3} />
      {state.error && <p role="alert">{state.error}</p>}
      {state.saved && <p role="status">Posted: {state.saved}</p>}
      <SubmitButton />
    </form>
  );
}

No useState for the textarea, no onChange, and no manual reset. Because the form resets after the action, a validation failure also clears the user's text in this example. For better UX on errors, return the submitted values in the state and use them as defaultValue, or switch that field to controlled. The details are covered in useActionState and form actions in modern React.

Comparing the Two Approaches

ConcernControlledUncontrolled
Source of truthReact stateDOM
Re-renders on typingEvery keystrokeNone
Live validation and formattingEasyNeeds event handlers or a library
Conditional UI based on valuesEasyHarder
Native validationWorksWorks
ResetSet state backform.reset() or automatic after actions
Boilerplatevalue plus onChange per fieldname per field
Programmatic value changesSet stateSet via ref (not recommended often)

Neither model is "correct." Choose per field based on what the UI needs:

  • Choose controlled when the UI reacts to the value while the user types: live search, formatting masks, character counters, dependent fields, or enabling a button only when inputs are valid.
  • Choose uncontrolled when you only need the value at submit time: simple contact forms, search forms submitted with a button, and forms using form actions.

You can mix them in a single form. A long signup form might keep most inputs uncontrolled and make just the username field controlled to show availability as the user types.

Performance on Large Forms

A controlled form with fifty fields stored in one useState object re-renders the whole form on every keystroke. For most forms this is fast enough. When it is not, you have a few options:

  • Move state down. Keep each field's state inside its own component and lift values up only on blur or submit.
  • Use uncontrolled inputs. No state changes means no re-renders.
  • Use a form library built on uncontrolled inputs. React Hook Form registers native inputs, reads values from the DOM, and only re-renders the parts that subscribe to specific fields or errors. The trade-offs versus Formik, which is built around controlled state, are compared in Formik vs React Hook Form.

Building Components That Support Both Modes

Native inputs support both modes: pass value for controlled, defaultValue for uncontrolled. Well-designed custom components do the same. Here is a toggle switch that works either way:

import { useState } from "react";

interface ToggleProps {
  checked?: boolean;
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  label: string;
}

export function Toggle({
  checked,
  defaultChecked = false,
  onCheckedChange,
  label,
}: ToggleProps) {
  const [internal, setInternal] = useState(defaultChecked);
  const isControlled = checked !== undefined;
  const on = isControlled ? checked : internal;

  function toggle() {
    const next = !on;
    if (!isControlled) setInternal(next);
    onCheckedChange?.(next);
  }

  return (
    <button
      type="button"
      role="switch"
      aria-checked={on}
      onClick={toggle}
      className={on ? "toggle toggle-on" : "toggle"}
    >
      {label}
    </button>
  );
}

Usage in both modes:

import { useState } from "react";
import { Toggle } from "./Toggle";

export function Settings() {
  const [emails, setEmails] = useState(true);

  return (
    <>
      {/* Uncontrolled: the Toggle manages its own state */}
      <Toggle label="Sounds" defaultChecked />

      {/* Controlled: the parent owns the state */}
      <Toggle label="Email alerts" checked={emails} onCheckedChange={setEmails} />
      <p>Email alerts are {emails ? "on" : "off"}.</p>
    </>
  );
}

The key line is const on = isControlled ? checked : internal, which is itself derived state. When a checked prop is passed, the internal state is ignored. This is the same contract that component libraries like Radix UI use, often wrapped in a reusable hook. The ideas behind it connect closely to derived state patterns.

A component should not switch modes during its life, for the same reason native inputs warn about it. Decide once, based on whether the parent passes checked.

Common Mistakes

  • Initializing a controlled value as undefined or null. Use an empty string, false, or another defined default.
  • Passing value without onChange. The input becomes read-only. Use defaultValue or add a handler.
  • Using both value and defaultValue. React warns, and defaultValue is ignored. Pick one.
  • Expecting defaultValue to update. It is only read on mount. To show a new initial value, change the input's key or make it controlled.
  • Reading e.target.value asynchronously in older patterns. In modern React, events are not pooled, but it is still clearer to read the value immediately and pass it along.
  • Controlling every field by habit. Simple forms are often shorter and faster with FormData.

Frequently Asked Questions (FAQ) About Controlled and Uncontrolled Components

A controlled input gets its value from React state through the value prop and updates through onChange. An uncontrolled input keeps its value in the DOM, starts from defaultValue, and you read it with a ref or FormData when you need it.

React's documentation describes both as valid. Controlled inputs are recommended when you need to react to every change. With React 19 form actions, uncontrolled inputs read through FormData have become a common and simple choice for forms that only need values on submit.

Usually because the value prop was undefined on the first render and a string later. React treats an input without a defined value as uncontrolled. Always provide a defined initial value, such as an empty string.

No. Browsers do not allow setting a file input's value from JavaScript, so it is always uncontrolled. Read selected files from e.target.files in onChange or from FormData on submit.

They avoid a re-render on every keystroke, which can matter in large forms or slow components. For typical forms, the difference is not noticeable, so pick the model that makes the code simpler.

It is built around uncontrolled inputs. The register function attaches a ref and listeners to native inputs and reads values from the DOM. For third-party components that only work in controlled mode, it provides a Controller component.

Conclusion

Controlled inputs let React own the value, which makes live validation, formatting, and dependent UI straightforward, at the cost of a re-render per keystroke and a bit more code. Uncontrolled inputs let the DOM own the value, which keeps forms short and fast and pairs naturally with FormData and React 19 form actions.

Pick per field rather than per app. Start with uncontrolled inputs and FormData for simple forms, switch individual fields to controlled when the UI needs to respond as the user types, and when you build reusable inputs, support both modes with the value or defaultValue contract that native elements already use.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading