Agents (llms.txt)

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