For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Sponsor

useControllableState

One state API for controlled and uncontrolled components.

A reusable control often needs two ways to own its value. In uncontrolled mode it remembers its own state, starting from a default. In controlled mode the parent supplies the current value and decides whether to accept requested changes.

For example, a standalone tab group can manage itself. A tab group synchronized with a URL or a settings form needs the parent to be the source of truth. Implementing both modes separately in every component invites subtle differences in when callbacks fire and which value is displayed.

One source of truth

This hook exposes one value and setter for both modes. Without value, the setter updates internal state. With value, it calls onChange to request a change and waits for the parent to pass the new value. It does not optimistically keep a second copy of controlled state.

Use it in custom controls that need both modes. Do not wrap a Base UI primitive merely to duplicate the state management it already provides. Keep the mode stable for the component's lifetime, and remember that defaultValue initializes state rather than resetting it on every render.

In the preview, both counters use the same component. The parent can reset the controlled counter because it owns that counter's value.

Preview

Uncontrolled

Controlled

Installation

The starter includes hooks/use-controllable-state.ts. Import the hook directly; no application-wide provider is required.

Usage

import { useControllableState } from "@/hooks/use-controllable-state";
 
const [value, setValue] = useControllableState({
  value: controlledValue,
  defaultValue: "overview",
  onChange: onValueChange,
});
setValue((previous) => (previous === "overview" ? "activity" : "overview"));

API Reference

APITypeBehavior / default
valueT or undefinedundefined means uncontrolled
defaultValueTRequired initial value
onChange(value: T) => voidCalled when a different value is requested
return[T, Dispatch<SetStateAction<T>>]Value and setter

Behavior

The existing Tabs uses this hook. It can also drive Accordion values, Switch checked state, or any custom control. Functional setters compose in uncontrolled mode without calling side effects from React state updaters. Controlled mode requests a change from the parent and does not maintain a shadow value. Keep the controlled/uncontrolled mode stable for a component's lifetime.

Implementation

"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import type { SetStateAction } from "react";

export interface ControllableStateOptions<T> {
  value?: T;
  defaultValue: T;
  onChange?: (value: T) => void;
}

export const useControllableState = <T>({
  value,
  defaultValue,
  onChange,
}: ControllableStateOptions<T>) => {
  const [internal, setInternal] = useState(defaultValue);
  const resolved = value === undefined ? internal : value;
  const current = useRef(resolved);
  const change = useRef(onChange);
  useEffect(() => {
    current.current = resolved;
    change.current = onChange;
  }, [resolved, onChange]);
  const controlled = value !== undefined;
  const setValue = useCallback(
    (action: SetStateAction<T>) => {
      const next =
        typeof action === "function"
          ? (action as (previous: T) => T)(current.current)
          : action;
      if (Object.is(current.current, next)) {
        return;
      }
      if (!controlled) {
        current.current = next;
        setInternal(next);
      }
      change.current?.(next);
    },
    [controlled]
  );
  return [resolved, setValue] as const;
};