@usefy/use-controllable-state
v0.25.1
Published
A React hook for controlled/uncontrolled state — the controllable state primitive every component library needs
Maintainers
Readme
Overview
useControllableState is part of the @usefy ecosystem — a collection of production-ready, TypeScript-first, SSR-safe React hooks. It lets a component support both a parent-controlled value/onChange API and self-managed state from a defaultValue, with a single hook and no branching at the call site — the exact pattern used by Radix UI and Mantine.
Features
- Controlled mode — when
valueis defined, the returned value mirrors it and the setter only callsonChange(the parent owns the value) - Uncontrolled mode — when
valueisundefined, the hook manages its own state seeded fromdefaultValue useStateergonomics — the setter accepts a value or an updater function(prev) => next- Stable & StrictMode-safe — the setter keeps a permanent identity;
onChangefires only on real changes and never double-fires under StrictMode/concurrent rendering - TypeScript-first — full type inference and exported types
- Tiny & tree-shakeable — zero dependencies, published as its own package
Installation
# npm
npm install @usefy/use-controllable-state
# yarn
yarn add @usefy/use-controllable-state
# pnpm
pnpm add @usefy/use-controllable-stateRequires React 18 or 19 (peerDependencies: "react": "^18.0.0 || ^19.0.0").
Quick Start
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
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>
| Option | Type | Description |
| -------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| value | T \| undefined | The controlled value. When not undefined, the hook is controlled: the returned value mirrors this prop and the setter only notifies onChange. |
| defaultValue | T \| undefined | Initial 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) => void | Called 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 firesonChangeon change; in controlled mode it only firesonChange(when the resolved value differs), leaving the parent to updatevalue.
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
onChangeon mount — it fires only when the value actually changes (compared withObject.is). - StrictMode-safe — in uncontrolled mode
onChangeis dispatched from an effect after commit, never from inside asetStateupdater, so it does not double-fire.
Testing
📊 View Detailed Coverage Report (GitHub Pages) — 22 tests, 100% statement coverage.
License
MIT © mirunamu
This package is part of the usefy monorepo.
