# useControllableState

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

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/use-controllable-state.json
```

[Registry JSON](https://mwui.vercel.app/r/use-controllable-state.json)

## Preview

```tsx
import * as React from "react";

import { Button } from "@/components/ui/button";

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

function Stepper({
  value,
  defaultValue = 0,
  onValueChange,
}: {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
}) {
  const [count, setCount] = useControllableState({
    value,
    defaultValue,
    onChange: onValueChange,
  });

  return (
    <div className="flex items-center gap-2">
      <Button size="icon-xs" variant="outline" onClick={() => setCount((previous) => previous - 1)}>
        -
      </Button>
      <span className="w-8 text-center text-sm tabular-nums">{count}</span>
      <Button size="icon-xs" variant="outline" onClick={() => setCount((previous) => previous + 1)}>
        +
      </Button>
    </div>
  );
}

export function Preview() {
  const [controlled, setControlled] = React.useState(5);

  return (
    <div className="flex flex-col gap-6 text-sm">
      <div className="flex flex-col gap-2">
        <p className="font-medium">Uncontrolled</p>
        <Stepper defaultValue={0} />
        <p className="text-xs text-muted-foreground">Owns its state.</p>
      </div>

      <div className="flex flex-col gap-2">
        <p className="font-medium">Controlled</p>
        <Stepper value={controlled} onValueChange={setControlled} />
        <p className="text-xs text-muted-foreground">
          Parent state: <span className="font-mono">{controlled}</span>
        </p>
      </div>

      <div className="flex flex-col gap-2">
        <p className="font-medium">Controlled and frozen</p>
        <Stepper value={42} />
        <p className="text-xs text-muted-foreground">
          No `onValueChange`, so the value never moves.
        </p>
      </div>
    </div>
  );
}
```


## Source

### hooks/use-controllable-state.ts

```ts
"use client";

import * as React from "react";

type ControllableStateUpdater<T> = (previous: T) => T;

type UseControllableStateOptions<T> = {
  value?: T | undefined;
  defaultValue: T | (() => T);
  onChange?: (value: T) => void;
  isEqual?: (a: T, b: T) => boolean;
};

function isControllableStateUpdater<T>(
  next: T | ControllableStateUpdater<T>,
): next is ControllableStateUpdater<T> {
  return typeof next === "function";
}

function useControllableState<T>({
  value,
  defaultValue,
  onChange,
  isEqual = Object.is,
}: UseControllableStateOptions<T>): [T, (next: T | ControllableStateUpdater<T>) => void] {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);

  const isControlled = value !== undefined;
  const resolved = isControlled ? value : uncontrolled;

  const resolvedRef = React.useRef(resolved);
  const onChangeRef = React.useRef(onChange);
  const isEqualRef = React.useRef(isEqual);

  React.useEffect(() => {
    resolvedRef.current = resolved;
    onChangeRef.current = onChange;
    isEqualRef.current = isEqual;
  });

  const setValue = React.useCallback(
    (next: T | ControllableStateUpdater<T>) => {
      const previous = resolvedRef.current;
      const nextValue = isControllableStateUpdater(next) ? next(previous) : next;

      if (isEqualRef.current(nextValue, previous)) return;

      resolvedRef.current = nextValue;
      if (!isControlled) setUncontrolled(nextValue);
      onChangeRef.current?.(nextValue);
    },
    [isControlled],
  );

  return [resolved, setValue];
}

export { useControllableState, type ControllableStateUpdater, type UseControllableStateOptions };
```



## 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.

```tsx
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

| Option         | Description                                                             |
| -------------- | ----------------------------------------------------------------------- |
| `value`        | The controlled value. Passing `undefined` selects uncontrolled mode.     |
| `defaultValue` | Initial value when uncontrolled. Accepts a lazy initializer.             |
| `onChange`     | Called with the next value whenever it changes, in either mode.          |
| `isEqual`      | Custom equality used to decide whether a change happened.                |

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

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

