Controlled vs Uncontrolled Pattern
Most developers encounter the controlled vs uncontrolled distinction first with form inputs. But the pattern is far more general: it applies to any component that has UI state — an accordion open/closed state, a dropdown's selected value, a date picker's current date, a modal's visibility. Understanding this duality makes you a much better component API designer.
Controlled: The Parent Owns State
In the controlled mode, the parent component holds the state and passes it down as a prop. The child never stores state internally — it just displays what it receives and fires callbacks when the user interacts. The parent decides whether (and how) to update state.
// Controlled Accordion — parent owns which panel is open
function ControlledAccordion({ openItem, onOpenItemChange, items }) {
return (
<div>
{items.map((item) => (
<div key={item.id}>
<button onClick={() => onOpenItemChange(item.id)}>
{item.title}
</button>
{openItem === item.id && (
<div>{item.content}</div>
)}
</div>
))}
</div>
)
}
// Parent usage — full control over which panel is open
function App() {
const [openItem, setOpenItem] = React.useState(null)
function handleOpenItemChange(id) {
// Parent can add business logic: "only allow opening if subscribed"
if (id === 'premium' && !user.isSubscribed) return
setOpenItem(id === openItem ? null : id) // toggle
}
return (
<ControlledAccordion
openItem={openItem}
onOpenItemChange={handleOpenItemChange}
items={items}
/>
)
}Uncontrolled: The Component Owns State
In the uncontrolled mode, the component manages its own state internally. The parent does not care about the current value — it only optionally gets notified about changes via a callback. This is simpler to consume when the parent doesn't need to drive or synchronize state.
// Uncontrolled Accordion — manages its own open/close state
function UncontrolledAccordion({ defaultOpenItem, onChange, items }) {
const [openItem, setOpenItem] = React.useState(defaultOpenItem ?? null)
function handleToggle(id) {
const next = id === openItem ? null : id
setOpenItem(next)
onChange?.(next) // optional notification — parent can listen but doesn't control
}
return (
<div>
{items.map((item) => (
<div key={item.id}>
<button onClick={() => handleToggle(item.id)}>
{item.title}
</button>
{openItem === item.id && (
<div>{item.content}</div>
)}
</div>
))}
</div>
)
}
// Parent usage — much simpler, just supplies initial state
function App() {
return (
<UncontrolledAccordion
defaultOpenItem="intro"
onChange={(id) => console.log('opened:', id)}
items={items}
/>
)
}Supporting Both Modes (The Best Component APIs)
Production-quality components like those in Radix UI, Headless UI, and MUI support both modes. They detect which mode is active by checking whether the controlling prop was passed. This is the same strategy React uses for <input value> (controlled) vs <input defaultValue> (uncontrolled).
function Accordion({ openItem, defaultOpenItem, onChange, items }) {
// If openItem prop is provided (even as null), we are in controlled mode
const isControlled = openItem !== undefined
// Internal state is only used in uncontrolled mode
const [internalOpen, setInternalOpen] = React.useState(defaultOpenItem ?? null)
// Derive the effective value depending on mode
const activeItem = isControlled ? openItem : internalOpen
function handleToggle(id) {
const next = id === activeItem ? null : id
if (!isControlled) {
setInternalOpen(next)
}
// Always fire the callback so the parent can observe even in uncontrolled mode
onChange?.(next)
}
return (
<div>
{items.map((item) => (
<div key={item.id}>
<button onClick={() => handleToggle(item.id)}>
{item.title}
</button>
{activeItem === item.id && <div>{item.content}</div>}
</div>
))}
</div>
)
}
// Uncontrolled (simpler consumer)
<Accordion defaultOpenItem="intro" onChange={log} items={items} />
// Controlled (parent drives state)
<Accordion openItem={open} onChange={setOpen} items={items} />The defaultValue Escape Hatch
Following React's own naming convention, use default* props for initial state in uncontrolled mode:
defaultOpenItem/openItem— for the accordion abovedefaultValue/value— for an input or selectdefaultChecked/checked— for a checkbox or radiodefaultOpen/open— for a modal or popoverdefaultSelected/selected— for a listbox or combobox
A Custom Text Input Supporting Both Modes
function TextInput({ value, defaultValue = '', onChange, ...rest }) {
const isControlled = value !== undefined
const [internalValue, setInternalValue] = React.useState(defaultValue)
const currentValue = isControlled ? value : internalValue
function handleChange(e) {
if (!isControlled) {
setInternalValue(e.target.value)
}
onChange?.(e)
}
return (
<input
value={currentValue}
onChange={handleChange}
{...rest}
/>
)
}
// Uncontrolled — self-manages, fires onChange for observation
<TextInput defaultValue="hello" onChange={(e) => console.log(e.target.value)} />
// Controlled — parent drives every keystroke
const [text, setText] = React.useState('')
<TextInput value={text} onChange={(e) => setText(e.target.value)} />When to Use Each Mode
Use controlled when... | Use uncontrolled when... |
|---|---|
Parent needs to read the current value | Parent only cares about final value (on submit) |
State must be synced between sibling components | Component can be fully self-sufficient |
Validation or side effects depend on the value | Simpler consumer DX is the priority |
Programmatic updates are needed (reset button) | The component is used in isolation |