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

Example · javascript
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.

Example · js
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.

Example · js
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.

Example · js
// 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

  • Be the first to comment on this lesson.