ReactControlled vs Uncontrolled Pattern

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.

JSX
// 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}
    />
  )
}
Note
Controlled mode is mandatory when two sibling components need to share the same state, or when a parent needs to programmatically change the component's state (e.g., "close all accordions when user navigates away").
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.

JSX
// 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).

JSX
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} />
Warning
Never switch a component between controlled and uncontrolled modes after it mounts. If `openItem` is `undefined` on first render and then becomes a real value on a later render, React will log a warning: "A component is changing an uncontrolled input to be controlled." Decide the mode at design time and stick with it.
The defaultValue Escape Hatch

Following React's own naming convention, use default* props for initial state in uncontrolled mode:

  • defaultOpenItem / openItem — for the accordion above

  • defaultValue / value — for an input or select

  • defaultChecked / checked — for a checkbox or radio

  • defaultOpen / open — for a modal or popover

  • defaultSelected / selected — for a listbox or combobox

A Custom Text Input Supporting Both Modes

JSX
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

Tip
When in doubt, uncontrolled with an `onChange` callback is the friendlier default. It covers 80% of use cases and requires less boilerplate from consumers. Controlled mode is an opt-in upgrade for power users.