Controlled, Uncontrolled & Hybrid Components
Design components that work both ways, so consumers choose how much control they need.
You know controlled inputs (React owns the value via value + onChange) and uncontrolled ones (the DOM owns it, read via a ref with defaultValue). The senior move is designing your own components so a consumer can use them either way — the pattern every good design system follows.
The rule that keeps you sane
A single component should be controlled or uncontrolled for a given prop, never flip-flopping. React even warns you: switching an input from controlled to uncontrolled mid-life (value going from a string to undefined) is a bug. Decide once, based on whether the prop is provided.
Support both with a small hook
function useControllable({ value, defaultValue, onChange }) {
const isControlled = value !== undefined;
const [internal, setInternal] = useState(defaultValue);
const current = isControlled ? value : internal;
const setValue = (next) => {
if (!isControlled) setInternal(next);
onChange?.(next);
};
return [current, setValue];
}Now a consumer who passes value drives the component fully; one who passes only defaultValue lets it manage itself. Same component, two modes, no branching in the consumer.
When to prefer each
- Controlled when you need to validate, format, or react to every change, or when several parts of the UI depend on the value.
- Uncontrolled for simple forms, file inputs (always uncontrolled), and when you only need the value at submit time — often paired with React 19 Actions and
FormData.
Example
import { useState } from 'react';
// A Rating that works controlled OR uncontrolled.
function useControllable({ value, defaultValue, onChange }) {
const isControlled = value !== undefined;
const [internal, setInternal] = useState(defaultValue);
const current = isControlled ? value : internal;
const setValue = (next) => {
if (!isControlled) setInternal(next);
onChange?.(next);
};
return [current, setValue];
}
function Rating({ value, defaultValue = 0, onChange, max = 5 }) {
const [rating, setRating] = useControllable({ value, defaultValue, onChange });
return (
<div role="radiogroup" aria-label="Rating">
{Array.from({ length: max }, (_, i) => i + 1).map((n) => (
<button
key={n}
aria-pressed={n <= rating}
onClick={() => setRating(n)}
>
{n <= rating ? 'star' : 'empty'}
</button>
))}
</div>
);
}
// Uncontrolled: <Rating defaultValue={3} />
// Controlled: <Rating value={score} onChange={setScore} />When to use it
- A design-system <DatePicker> that works as a controlled component when a form library manages value/onChange, yet also works as an uncontrolled component with just a defaultValue for quick prototypes.
- A <RichTextEditor> exposing both a value prop for real-time server sync and defaultValue for standalone CMS widgets that only read content on submit.
- A <Toggle> switch that the user clicks freely in uncontrolled mode but can be overridden externally by a parent reset action in controlled mode.
More examples
useControlled hook
The hook locks the controlled/uncontrolled mode at mount time and returns the authoritative value and setter regardless of which mode the consumer chose.
function useControlled(controlledValue, defaultValue) {
const isControlled = React.useRef(controlledValue !== undefined);
const [internalValue, setInternalValue] = React.useState(defaultValue);
const value = isControlled.current ? controlledValue : internalValue;
const setValue = isControlled.current
? () => {}
: setInternalValue;
return [value, setValue];
}Hybrid Toggle component
Toggle behaves as a controlled component when value is supplied and as an uncontrolled one when only defaultValue is given, using the useControlled hook.
function Toggle({ value, defaultValue = false, onChange }) {
const [checked, setChecked] = useControlled(value, defaultValue);
function handleClick() {
const next = !checked;
setChecked(next);
onChange?.(next);
}
return (
<button role="switch" aria-checked={checked} onClick={handleClick}>
{checked ? 'On' : 'Off'}
</button>
);
}Controlled and uncontrolled usage
The same Toggle component works in both modes; the parent opts into control by supplying value, enabling programmatic resets via state updates.
// Uncontrolled — Toggle owns its own state
<Toggle defaultValue={true} onChange={(v) => console.log('toggled:', v)} />
// Controlled — parent owns and resets state
function Form() {
const [enabled, setEnabled] = React.useState(false);
return (
<>
<Toggle value={enabled} onChange={setEnabled} />
<button onClick={() => setEnabled(false)}>Reset</button>
</>
);
}
Discussion