Controlled vs Uncontrolled Components — Choose who owns state: the component or its parent
Recipe
// Controlled — parent owns state
function ControlledInput() {
const [value, setValue] = useState("");
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// Uncontrolled — DOM owns state
function UncontrolledInput() {
const ref = useRef<HTMLInputElement>(null);
const handleSubmit = () => console.log(ref.current?.value);
return <input ref={ref} defaultValue="" />;
}
// Flexible — supports both modes
function FlexibleInput({
value: controlledValue,
defaultValue = "",
onChange,
}: {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}) {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!isControlled) setInternalValue(e.target.value);
onChange?.(e.target.value);
};
return <input value={value} onChange={handleChange} />;
}When to reach for this: Every interactive component must decide who owns its state. Use controlled when the parent needs to read or modify the value. Use uncontrolled for simpler cases where the component manages itself. Build library components to support both.
Working Example
import { useState, useRef, useCallback, type ReactNode } from "react";
// A Toggle component that supports controlled and uncontrolled usage
interface ToggleProps {
pressed?: boolean;
defaultPressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
children: ReactNode;
}
function Toggle({
pressed: controlledPressed,
defaultPressed = false,
onPressedChange,
children,
}: ToggleProps) {
const [internalPressed, setInternalPressed] = useState(defaultPressed);
const isControlled = controlledPressed !== undefined;
const pressed = isControlled ? controlledPressed : internalPressed;
const handleClick = useCallback(() => {
const next = !pressed;
if (!isControlled) {
setInternalPressed(next);
}
onPressedChange?.(next);
}, [pressed, isControlled, onPressedChange]);
return (
<button
type="button"
role="switch"
aria-checked={pressed}
onClick={handleClick}
className={`px-4 py-2 rounded-full transition-colors ${
pressed
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700"
}`}
>
{children}
</button>
);
}
// --- Usage examples ---
// Uncontrolled — component manages its own state
function SimpleToggle() {
return (
<Toggle
defaultPressed={false}
onPressedChange={(p) => console.log("Toggled:", p)}
>
Dark Mode
</Toggle>
);
}
// Controlled — parent owns and can override state
function SyncedToggles() {
const [enabled, setEnabled] = useState(false);
return (
<div className="flex gap-4">
<Toggle pressed={enabled} onPressedChange={setEnabled}>
Toggle A
</Toggle>
<Toggle pressed={enabled} onPressedChange={setEnabled}>
Toggle B
</Toggle>
<p>Both are: {enabled ? "ON" : "OFF"}</p>
</div>
);
}What this demonstrates:
- A single component that works in both controlled and uncontrolled modes
- The
isControlledcheck determines which state source to use - Uncontrolled mode uses
defaultPressedfor initial state - Controlled mode lets the parent synchronize multiple instances
Deep Dive
How It Works
- Controlled components receive their current value via props and notify changes via callbacks. The parent is the single source of truth.
- Uncontrolled components manage their own state internally and optionally notify the parent through callbacks.
- The
default*prop convention (e.g.,defaultValue,defaultChecked,defaultPressed) signals uncontrolled initial state. - A component detects its mode by checking if the controlled prop is
undefined. - React 19 form actions with
useActionStatecan simplify form patterns, but the controlled/uncontrolled distinction still applies.
Parameters & Return Values
| Prop Convention | Mode | Purpose |
|---|---|---|
value | Controlled | Current value, set by parent |
defaultValue | Uncontrolled | Initial value, component manages afterward |
onChange | Both | Callback notifying parent of changes |
ref | Uncontrolled | Imperative access to read DOM value |
Variations
useControllableState hook — extract the pattern into a reusable hook:
function useControllableState<T>({
value: controlledValue,
defaultValue,
onChange,
}: {
value?: T;
defaultValue: T;
onChange?: (value: T) => void;
}): [T, (next: T) => void] {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const setValue = useCallback(
(next: T) => {
if (!isControlled) setInternalValue(next);
onChange?.(next);
},
[isControlled, onChange]
);
return [value, setValue];
}
// Usage inside any component
function Slider({ value, defaultValue = 0, onChange, min = 0, max = 100 }: SliderProps) {
const [current, setCurrent] = useControllableState({
value,
defaultValue,
onChange,
});
// ... render with `current` and `setCurrent`
}React 19 form actions — uncontrolled forms with server actions:
function ContactForm() {
async function submitAction(formData: FormData) {
"use server";
const email = formData.get("email") as string;
await sendEmail(email);
}
return (
<form action={submitAction}>
<input name="email" type="email" defaultValue="" />
<button type="submit">Send</button>
</form>
);
}TypeScript Notes
- Use optional typing (
value?: T) for the controlled prop soundefinedsignals uncontrolled mode. - Make
defaultValuerequired whenvalueis not provided, or give it a sensible default. - Consider a discriminated union type for strict either-or enforcement, though it reduces flexibility.
Gotchas
-
Switching between controlled and uncontrolled — Changing
valuefromundefinedto a defined value (or vice versa) during the component lifecycle causes bugs. React warns about this. Fix: Decide the mode at mount time and stick with it. Use a ref to track the initial mode. -
Controlled input with delayed state update — If the
onChangehandler updates state asynchronously (e.g., debounced), the input appears to freeze. Fix: Update local state immediately and debounce the side effect, not the state update. -
Missing onChange on controlled component — Providing
valuewithoutonChangecreates a read-only input. React warns. Fix: Always pairvaluewithonChange, or usereadOnlyif intentional. -
defaultValue changing after mount — Changing
defaultValueafter the first render has no effect. Fix: Use akeyprop to remount the component if the initial value needs to reset.
Alternatives
| Approach | Trade-off |
|---|---|
| Controlled | Full parent control; requires state management in parent |
| Uncontrolled | Simpler; harder for parent to read or sync state |
| Flexible (both modes) | Best for libraries; more implementation complexity |
| React 19 form actions | Great for forms; uncontrolled with server-side handling |
| State management library | Zustand or Redux can act as the controller for complex forms |
FAQs
What is the key difference between controlled and uncontrolled components?
- Controlled: the parent owns the state via props (
value+onChange). The parent is the single source of truth. - Uncontrolled: the component manages its own state internally. The parent can read it via
refor receive notifications via callbacks. - The
default*prop convention signals uncontrolled initial state.
How does a component detect whether it is in controlled or uncontrolled mode?
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;- Check if the controlled prop (
value) isundefined. - If defined, use the controlled value. If
undefined, use internal state.
What is the useControllableState hook and when should you use it?
- It is a reusable hook that encapsulates the controlled/uncontrolled detection logic.
- Use it inside any component that needs to support both modes to avoid duplicating the pattern.
- It returns
[value, setValue]and handles internal state, controlled passthrough, andonChangecallbacks.
How do React 19 form actions relate to controlled vs uncontrolled?
<form action={submitAction}>
<input name="email" type="email" defaultValue="" />
<button type="submit">Send</button>
</form>- Form actions use uncontrolled inputs with
defaultValueand read values fromFormData. - The controlled/uncontrolled distinction still applies; form actions favor the uncontrolled approach.
Gotcha: What happens if you switch between controlled and uncontrolled mode during the component lifecycle?
- Changing
valuefromundefinedto a defined value (or vice versa) causes bugs and React warnings. - The mode should be decided at mount time and remain consistent.
- Fix: use a ref to track the initial mode and warn if it changes.
Gotcha: Why does a controlled input with a debounced onChange appear to freeze?
- If the
onChangehandler updates state asynchronously (e.g., debounced), the input'svalueprop does not update immediately. - React re-renders with the stale value, making the input appear frozen.
- Fix: update local state immediately and debounce only the side effect, not the state update.
How do you type a flexible component that supports both controlled and uncontrolled modes in TypeScript?
interface ToggleProps {
pressed?: boolean; // controlled
defaultPressed?: boolean; // uncontrolled
onPressedChange?: (pressed: boolean) => void;
children: ReactNode;
}- Use optional typing (
pressed?: boolean) soundefinedsignals uncontrolled mode. - Give
defaultPresseda sensible default value.
Can you use a discriminated union type to enforce strictly controlled or uncontrolled props in TypeScript?
- Yes, you can define separate type branches for controlled and uncontrolled props using a discriminated union.
- However, this reduces flexibility because consumers cannot omit both
valueanddefaultValue. - Most libraries prefer the flexible approach with optional props for both modes.
Why does changing defaultValue after mount have no effect?
defaultValueonly sets the initial state on the first render.- React ignores changes to
defaultValueon subsequent renders because internal state is already initialized. - Fix: use a
keyprop to remount the component if the initial value needs to reset.
What happens if you provide value without onChange on a controlled input?
- The input becomes read-only because React enforces the
valueprop on every render. - React warns in the console about a missing
onChangehandler. - Fix: always pair
valuewithonChange, or explicitly add thereadOnlyattribute if intentional.
When should you use a controlled component vs an uncontrolled component?
- Use controlled when the parent needs to read, validate, or synchronize the value across multiple components.
- Use uncontrolled when the component can manage itself and the parent only needs the value on submit.
- Build library components to support both modes for maximum flexibility.
Related
- Compound Components — Compound components often need controlled/uncontrolled support
- State Machines — Formalize state transitions for complex controlled components
- Context Patterns — Share controlled state across compound component trees