useControllableState
Merge a controlled prop with an uncontrolled fallback. The single returned setter routes to onChange when controlled or to internal state when uncontrolled.
Dependencies
Source Code
/**
* Ported from Radix UI (MIT)
* https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
*
* Adapted for Playstack: React 19 idioms, named exports only, no forwardRef.
*/
import { useCallback, useRef, useState } from 'react';
import { useStableCallback } from '@/foundations/hooks/use-stable-callback';
type SetStateFn<T> = (prevState: T | undefined) => T;
export interface UseControllableStateOptions<T> {
prop?: T | undefined;
defaultProp?: T | undefined;
onChange?: (next: T) => void;
}
export const useControllableState = <T>({
prop,
defaultProp,
onChange,
}: UseControllableStateOptions<T>): readonly [
T | undefined,
(next: T | SetStateFn<T>) => void,
] => {
const [uncontrolled, setUncontrolled] = useState<T | undefined>(defaultProp);
const isControlled = prop !== undefined;
const value = isControlled ? prop : uncontrolled;
const stableOnChange = useStableCallback(onChange);
// Assigned during render (not in an effect) so the refs always reflect the
// latest committed value. Functional updaters in controlled mode resolve
// against the live controlled prop, not a stale snapshot.
const propRef = useRef(prop);
propRef.current = prop;
const uncontrolledRef = useRef(uncontrolled);
uncontrolledRef.current = uncontrolled;
const isControlledRef = useRef(isControlled);
isControlledRef.current = isControlled;
const setValue = useCallback(
(next: T | SetStateFn<T>) => {
if (isControlledRef.current) {
const current = propRef.current;
const resolved =
typeof next === 'function' ? (next as SetStateFn<T>)(current) : next;
if (resolved !== current) {
stableOnChange(resolved);
}
} else {
const current = uncontrolledRef.current;
const resolved =
typeof next === 'function' ? (next as SetStateFn<T>)(current) : next;
if (resolved !== current) {
// useState's setter natively supports functional updaters, but we
// pre-resolve here so onChange and setUncontrolled see the same value.
setUncontrolled(resolved);
stableOnChange(resolved);
}
}
},
[stableOnChange]
);
return [value, setValue] as const;
}; API Reference
| Prop | Default | Type | Description |
|---|---|---|---|
prop | - | T | undefined | Controlled value. When provided, the hook ignores its internal state and routes setter calls to onChange. |
defaultProp | - | T | undefined | Uncontrolled default. Used as the initial internal state when prop is undefined. |
onChange | - | (next: T) => void | Called whenever the resolved value changes, in both controlled and uncontrolled modes. |
Returns [value, setValue]. The setter accepts a value or a functional updater. In controlled mode the functional updater resolves against the current controlled prop.
Transitioning between controlled and uncontrolled modes mid-lifecycle is not supported — internal state will retain the original defaultProp.
Examples
Both modes in one component
import { useControllableState } from '@/foundations/hooks/use-controllable-state/use-controllable-state';
interface DisclosureProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
const Disclosure = ({ open, defaultOpen, onOpenChange, ...rest }: DisclosureProps) => {
const [isOpen, setOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
return <button onClick={() => setOpen((prev) => !prev)}>{isOpen ? 'Close' : 'Open'}</button>;
};
Previous
Component patterns
Next
useDetectDevice