Skip to content
usefy

useControllableState

State & data

The controlled/uncontrolled state primitive every component library needs

Install

$ npm install @usefy/use-controllable-state

Quick start

useControllableState.tsx
import { useControllableState } from "@usefy/use-controllable-state";

// A switch that is controllable but works standalone.
function Switch({
  checked,
  defaultChecked,
  onCheckedChange,
}: {
  checked?: boolean;
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
}) {
  const [on, setOn] = useControllableState({
    value: checked,
    defaultValue: defaultChecked ?? false,
    onChange: onCheckedChange,
  });

  return (
    <button role="switch" aria-checked={on} onClick={() => setOn((p) => !p)}>
      {on ? "On" : "Off"}
    </button>
  );
}

// Uncontrolled — manages its own state:
<Switch defaultChecked />;

// Controlled — the parent owns the value:
const [value, setValue] = useState(false);
<Switch checked={value} onCheckedChange={setValue} />;

API reference

const [value, setValue] = useControllableState<T>({
  value,        // T | undefined — the controlled value (defined ⇒ controlled)
  defaultValue, // T | undefined — initial value used while uncontrolled
  onChange,     // ((value: T) => void) | undefined — called on every change
});

Options — UseControllableStateOptions<T>

OptionTypeDescription
valueT | undefinedThe controlled value. When not undefined, the hook is controlled: the returned value mirrors this prop and the setter only notifies onChange.
defaultValueT | undefinedInitial value used in uncontrolled mode (when value is undefined). Passed to useState, so a function value is treated as a lazy initializer.
onChange(value: T) => voidCalled with the next value on every change. Its identity may change between renders without re-subscribing — the latest callback is always used.

Returns — UseControllableStateReturn<T>

A readonly [value, setValue] tuple with the same shape as useState:

  • value: T — the effective value (the controlled prop when controlled, otherwise the internal state).
  • setValue: Dispatch<SetStateAction<T>> — accepts a next value or an updater (prev) => next. In uncontrolled mode it updates internal state and fires onChange on change; in controlled mode it only fires onChange (when the resolved value differs), leaving the parent to update value.

Behavior notes

  • Mode is per-render — it is decided each render by whether value === undefined. Switching between defined/undefined switches modes; components should avoid doing so mid-lifecycle (same caveat as React's own controlled inputs).
  • No onChange on mount — it fires only when the value actually changes (compared with Object.is).
  • StrictMode-safe — in uncontrolled mode onChange is dispatched from an effect after commit, never from inside a setState updater, so it does not double-fire.

Go deeper

This page is the quick reference. For every example, prop, and edge case, read the full README — or open Storybook to change props live.

More in state & data