mwui
GitHub

useControllableState

A hook that makes any component work controlled or uncontrolled from one state API.

Uncontrolled

0

Owns its state.

Controlled

5

Parent state: 5

Controlled and frozen

42

No `onValueChange`, so the value never moves.

Installation

Usage

Every reusable component eventually needs to work both ways: controlled by a parent through value / onValueChange, and uncontrolled through defaultValue. This hook collapses both into a single useState-shaped API, so the component body never branches on which mode it is in.

import { useControllableState } from "@/hooks/use-controllable-state";

function Rating({ value, defaultValue = 0, onValueChange }: RatingProps) {
  const [rating, setRating] = useControllableState({
    value,
    defaultValue,
    onChange: onValueChange,
  });

  return <button onClick={() => setRating((previous) => previous + 1)}>{rating}</button>;
}

<Rating defaultValue={3} /> manages its own state. <Rating value={rating} onValueChange={setRating} /> defers to the parent. The component itself does not know the difference.

Behavior

  • The setter accepts a value or an updater function, exactly like useState.
  • onChange fires only when the value actually changes, compared with Object.is by default.
  • In controlled mode the hook never writes to internal state — if the parent ignores onChange, the value does not move. That is what makes a controlled component genuinely controlled.
  • The setter identity is stable, so it is safe in dependency arrays and in memoized children.

Options

OptionDescription
valueThe controlled value. Passing undefined selects uncontrolled mode.
defaultValueInitial value when uncontrolled. Accepts a lazy initializer.
onChangeCalled with the next value whenever it changes, in either mode.
isEqualCustom equality used to decide whether a change happened.

Pass isEqual when the value is a structure that is rebuilt on every render:

const [range, setRange] = useControllableState({
  value,
  defaultValue: null,
  onChange: onValueChange,
  isEqual: (a, b) => a?.start === b?.start && a?.end === b?.end,
});