Controlled vs Uncontrolled Abstractions
On this page
What Controlled and Uncontrolled Mean
- Controlled: parent owns state and passes value plus onChange.
- Uncontrolled: component owns internal state, parent configures via defaults.
- Production rule: choose based on ownership, coordination needs, and persistence requirements.
When Controlled Mode Is Required
- State must sync to URL, storage, or server state.
- Multiple components must coordinate the same value.
- Parent must enforce invariants and validation.
- Testing requires explicit state control.
When Uncontrolled Mode Is Better
- Local interaction state that does not affect siblings.
- Simple components where wiring value and onChange would add noise.
- Performance sensitive inputs where parent rerenders are expensive.
Dual Mode Design Rules
- Support both modes only if there is a clear product need.
- Use an explicit controlled check: value prop provided means controlled.
- Keep one source of truth at a time. Do not mix internal and external state.
- Always call onChange when user intent changes value, even in uncontrolled mode if provided.
Example: Dual Mode Hook
function useControllableState<T>({
value,
defaultValue,
onChange,
}: {
value?: T;
defaultValue: T;
onChange?: (v: T) => void;
}) {
const [internal, setInternal] = React.useState<T>(defaultValue);
const isControlled = value !== undefined;
const current = isControlled ? (value as T) : internal;
const set = React.useCallback((next: T) => {
if (!isControlled) setInternal(next);
if (onChange) onChange(next);
}, [isControlled, onChange]);
return [current, set] as const;
}
Example: Component Using Dual Mode
function Toggle({
value,
defaultValue = false,
onChange,
}: {
value?: boolean;
defaultValue?: boolean;
onChange?: (v: boolean) => void;
}) {
const [v, setV] = useControllableState({
value,
defaultValue,
onChange,
});
return (
<button onClick={() => setV(!v)} aria-pressed={v}>
{v ? "On" : "Off"}
</button>
);
}
Operational Failure Modes
- Drift: internal state and external props disagree due to mixed ownership.
- Lost updates: onChange called but parent does not update value in controlled mode.
- Remount reset: uncontrolled state resets when keys change or route remount occurs.
- Double validation: both parent and child enforce different rules.
Debug Playbook
- If UI does not update in controlled mode, check parent state update path.
- If state resets unexpectedly, inspect keys and route boundaries.
- If value oscillates, search for two sources of truth writing in different places.
Code Review Checklist
- Controlled detection is explicit and consistent.
- No internal state writes in controlled mode.
- onChange is always fired on user intent changes.
- Default values are only used in uncontrolled mode.