React

useControllableState

Controlled and uncontrolled state behind a single interface.

Behaviour

Without a value, it keeps its own state from the default
const screen = await render(<Counter />);
await userEvent.click(screen.getByRole("button", { name: "Add" }));
await expect.element(screen.getByRole("status")).toHaveTextContent("1");
With a value, it shows that value and only asks to change it
const onChange = vi.fn();
const screen = await render(<Counter value={5} onChange={onChange} />);
await userEvent.click(screen.getByRole("button", { name: "Add" }));
expect(onChange).toHaveBeenCalledWith(6);
await expect.element(screen.getByRole("status")).toHaveTextContent("5");
A controlled value follows its owner
function Owner() {
  const [value, setValue] = useState(10);
  return <Counter value={value} onChange={setValue} />;
}
const screen = await render(<Owner />);
await userEvent.click(screen.getByRole("button", { name: "Add" }));
await expect.element(screen.getByRole("status")).toHaveTextContent("11");
onChange hears changes in either mode, and not a set to the same value
const onChange = vi.fn();
const screen = await render(<Counter onChange={onChange} />);
await userEvent.click(screen.getByRole("button", { name: "Same" }));
await userEvent.click(screen.getByRole("button", { name: "Add" }));
expect(onChange.mock.calls).toEqual([[1]]);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/use-controllable-state.json
Source

src/primitives/use-controllable-state.ts

import { useCallback, useState } from "react";

/**
 * State a parent may own. Passing `value` (anything but `undefined`) makes it
 * controlled; otherwise it starts at `defaultValue` and is kept here. Either way
 * `onChange` hears every change, and only real changes.
 */
export function useControllableState<T>(
  value: T | undefined,
  defaultValue: T,
  onChange?: (value: T) => void,
): [T, (next: T) => void] {
  const [internal, setInternal] = useState(defaultValue);
  const controlled = value !== undefined;
  const current = controlled ? value : internal;

  const set = useCallback(
    (next: T) => {
      if (Object.is(next, current)) return;
      if (!controlled) setInternal(next);
      onChange?.(next);
    },
    [controlled, current, onChange],
  );

  return [current, set];
}