Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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.
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:
isControlled check determines which state source to usedefaultPressed for initial statedefault* prop convention (e.g., defaultValue, defaultChecked, defaultPressed) signals uncontrolled initial state.undefined.useActionState can simplify form patterns, but the controlled/uncontrolled distinction still applies.| 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 |
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>
);
}value?: T) for the controlled prop so undefined signals uncontrolled mode.defaultValue required when value is not provided, or give it a sensible default.Switching between controlled and uncontrolled - Changing value from undefined to 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 onChange handler 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 value without onChange creates a read-only input. React warns. Fix: Always pair value with onChange, or use readOnly if intentional.
defaultValue changing after mount - Changing defaultValue after the first render has no effect. Fix: Use a key prop to remount the component if the initial value needs to reset.
| 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 |
value + onChange). The parent is the single source of truth.ref or receive notifications via callbacks.default* prop convention signals uncontrolled initial state.const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;value) is undefined.undefined, use internal state.[value, setValue] and handles internal state, controlled passthrough, and onChange callbacks.<form action={submitAction}>
<input name="email" type="email" defaultValue="" />
<button type="submit">Send</button>
</form>defaultValue and read values from FormData.value from undefined to a defined value (or vice versa) causes bugs and React warnings.onChange handler updates state asynchronously (e.g., debounced), the input's value prop does not update immediately.interface ToggleProps {
pressed?: boolean; // controlled
defaultPressed?: boolean; // uncontrolled
onPressedChange?: (pressed: boolean) => void;
children: ReactNode;
}pressed?: boolean) so undefined signals uncontrolled mode.defaultPressed a sensible default value.value and defaultValue.defaultValue only sets the initial state on the first render.defaultValue on subsequent renders because internal state is already initialized.key prop to remount the component if the initial value needs to reset.value prop on every render.onChange handler.value with onChange, or explicitly add the readOnly attribute if intentional.Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥