Skip to content
React rc patterns 4 min read

Controlled & Uncontrolled Components

A well-designed React component often needs to work in two modes: sometimes the parent wants to own the state (“controlled”), and sometimes the component should just manage itself (“uncontrolled”). The same <input> you use every day already does this — pass value and it is controlled, pass defaultValue and it manages its own state. Building your own components to support both modes makes them far more flexible and reusable. This page shows the patterns that make a single component API gracefully handle both cases.

Controlled vs. uncontrolled

A component is controlled when its state lives in the parent and is fed back in through props. The component renders exactly what it is told and reports changes up via a callback. It is uncontrolled when it keeps its own internal state and the parent only sets an initial value.

AspectControlledUncontrolled
State ownerParentThe component itself
Initial value prop(not used)defaultValue
Live value propvalue(not used)
Change notificationonChange is the source of truthonChange is optional notification
Use whenParent needs to read/override the valueThe component can manage itself

The native input is the canonical example:

// Controlled: parent owns the value
<input value={name} onChange={(e) => setName(e.target.value)} />

// Uncontrolled: input owns its value, parent sets the seed
<input defaultValue="Ada" />

Your custom components should follow the same convention so they feel native to anyone using them.

The value ?? internalState pattern

The trick to supporting both modes in one component is to decide, at render time, whether a controlling prop was supplied. If value is provided, use it; otherwise fall back to your own state. The nullish coalescing operator (??) expresses this cleanly.

import { useState, useCallback } from 'react';

function Toggle({ on, defaultOn = false, onChange }) {
  const [internalOn, setInternalOn] = useState(defaultOn);

  // Controlled if `on` was passed; otherwise uncontrolled.
  const isControlled = on !== undefined;
  const value = on ?? internalOn;

  const toggle = useCallback(() => {
    const next = !value;
    if (!isControlled) {
      setInternalOn(next);
    }
    onChange?.(next);
  }, [value, isControlled, onChange]);

  return (
    <button type="button" aria-pressed={value} onClick={toggle}>
      {value ? 'On' : 'Off'}
    </button>
  );
}

Notice two things. First, the component only updates its own state when it is uncontrolled — when controlled, the parent is responsible for re-rendering with a new on. Second, onChange?.(next) always fires, so the parent is notified in both modes.

Tip: Determine isControlled by checking prop !== undefined, not by truthiness. A controlled boolean of false or a controlled string of "" is still controlled, and !value would mislabel it.

Using it both ways:

function App() {
  const [pinned, setPinned] = useState(false);

  return (
    <>
      {/* Controlled: App owns the state */}
      <Toggle on={pinned} onChange={setPinned} />

      {/* Uncontrolled: Toggle manages itself, App just listens */}
      <Toggle defaultOn onChange={(v) => console.log('toggled to', v)} />
    </>
  );
}

Output:

toggled to false
toggled to true

Extracting a reusable hook

Because this logic repeats across inputs, selects, toggles, and dialogs, it is worth extracting into a custom hook. It returns the resolved value and a setter that respects the controlled/uncontrolled distinction.

import { useState, useCallback } from 'react';

function useControllableState({ value, defaultValue, onChange }) {
  const [internal, setInternal] = useState(defaultValue);
  const isControlled = value !== undefined;
  const resolved = isControlled ? value : internal;

  const setValue = useCallback(
    (next) => {
      if (!isControlled) setInternal(next);
      onChange?.(next);
    },
    [isControlled, onChange]
  );

  return [resolved, setValue];
}

function Rating({ value, defaultValue = 0, onChange, max = 5 }) {
  const [rating, setRating] = useControllableState({ value, defaultValue, onChange });

  return (
    <div role="radiogroup" aria-label="Rating">
      {Array.from({ length: max }, (_, i) => i + 1).map((star) => (
        <button
          key={star}
          type="button"
          aria-checked={star === rating}
          onClick={() => setRating(star)}
        >
          {star <= rating ? '★' : '☆'}
        </button>
      ))}
    </div>
  );
}

This is essentially what libraries like Radix UI and React Aria ship internally. One hook keeps every interactive component consistent.

Prop naming conventions

Following established names lets consumers reuse intuition rather than reading your docs:

PropMeaning
valueThe controlled value (component is controlled when present)
defaultValueThe initial value for uncontrolled mode
onChange / onValueChangeFired whenever the value changes, in both modes
onChange for booleansOften paired as checked / defaultChecked

Warning: Do not let a component switch between controlled and uncontrolled across renders. If value goes from a defined value to undefined, React (and your users) will see confusing state jumps. Pick one mode per mounted instance and keep it stable.

Best practices

  • Detect control with prop !== undefined, never with truthiness, so falsy controlled values still work.
  • Always call onChange?.() in both modes so parents can observe changes even when uncontrolled.
  • Only mutate internal state when uncontrolled; otherwise let the parent drive re-renders.
  • Mirror native names: value/defaultValue and checked/defaultChecked.
  • Keep a component’s mode stable for its entire lifetime to avoid controlled-to-uncontrolled warnings.
  • Extract a useControllableState hook once and reuse it across every interactive component.
  • In development, optionally warn if both value and defaultValue are passed together.
Last updated June 14, 2026
Was this helpful?