LOKE Design System
Hooks

useControllableState

A small hook for building components that support both controlled and uncontrolled usage with one consistent API.

useControllableState

useControllableState lets a component work in either controlled or uncontrolled mode using a single implementation:

  • Controlled: parent provides prop and handles updates via onChange.
  • Uncontrolled: component manages its own internal state from defaultProp.

This pattern is common for inputs, toggles, and compound widgets where consumers may want to fully control state or let the component manage it.

This hook mirrors the React pattern used by many libraries (e.g., radix). It ensures consistent behavior and emits onChange when value changes in either mode.


API

function useControllableState<T>(params: {
  prop?: T;                 // Controlled value (if defined)
  defaultProp: T;           // Initial value for uncontrolled mode
  onChange?: (state: T) => void; // Called when value changes
  caller?: string;          // Optional name for dev warnings
}): [value: T, setValue: React.Dispatch<React.SetStateAction<T>>];

Prop

Type


Usage

Uncontrolled usage (internal state)

import * as React from 'react';
import { useControllableState } from '@loke/ui/use-controllable-state';

function ToggleUncontrolled() {
  const [checked, setChecked] = useControllableState<boolean>({
    defaultProp: false,
    onChange: (next) => {
      // eslint-disable-next-line no-console
      console.log('uncontrolled changed ->', next);
    },
  });

  return (
    <button
      type="button"
      aria-pressed={checked}
      className={checked ? 'bg-primary text-primary-foreground px-3 py-2 rounded'
                         : 'border px-3 py-2 rounded'}
      onClick={() => setChecked((v) => !v)}
    >
      {checked ? 'On' : 'Off'}
    </button>
  );
}

Controlled usage (parent owns the value)

import * as React from 'react';
import { useControllableState } from '@loke/ui/use-controllable-state';

function ToggleControlled({
  value,
  onChange,
}: { value: boolean; onChange: (next: boolean) => void }) {
  const [checked, setChecked] = useControllableState<boolean>({
    prop: value,
    defaultProp: false, // ignored because `prop` is provided
    onChange,
    caller: 'ToggleControlled',
  });

  return (
    <button
      type="button"
      aria-pressed={checked}
      className={checked ? 'bg-primary text-primary-foreground px-3 py-2 rounded'
                         : 'border px-3 py-2 rounded'}
      onClick={() => setChecked((v) => !v)}
    >
      {checked ? 'On' : 'Off'}
    </button>
  );
}

export function ControlledExample() {
  const [value, setValue] = React.useState(false);

  return (
    <div className="flex items-center gap-3">
      <ToggleControlled value={value} onChange={setValue} />
      <span className="text-sm text-muted-foreground">value: {String(value)}</span>
      <button
        type="button"
        className="border px-2 py-1 rounded"
        onClick={() => setValue(false)}
      >
        Reset
      </button>
    </div>
  );
}

Text input with controlled/uncontrolled support

import * as React from 'react';
import { useControllableState } from '@loke/ui/use-controllable-state';

type TextInputProps = {
  value?: string;                  // controlled
  defaultValue?: string;           // uncontrolled
  onChange?: (v: string) => void;
  placeholder?: string;
};

export function TextInput({
  value,
  defaultValue = '',
  onChange,
  placeholder,
}: TextInputProps) {
  const [val, setVal] = useControllableState<string>({
    prop: value,
    defaultProp: defaultValue,
    onChange,
    caller: 'TextInput',
  });

  return (
    <input
      className="border rounded px-3 py-2"
      value={val}
      onChange={(e) => setVal(e.target.value)}
      placeholder={placeholder}
    />
  );
}

How it works

  • When prop is provided (not undefined): the hook is in controlled mode.
    • The returned value equals prop.
    • setValue(next) invokes onChange(nextResolved) if the next value differs from the current prop.
  • When prop is not provided: the hook is in uncontrolled mode.
    • The hook stores internal state initialized from defaultProp.
    • setValue(next) updates internal state and then calls onChange(updated).
  • In development, if a component switches between controlled and uncontrolled at runtime, the hook logs a warning with the caller label (to help catch invalid usage).

The setter accepts either a value or an updater function ((prev) => next), matching the React setState API.


Patterns

  • Prefer controlled mode when the value is derived from a global store, form library, or route state.
  • Prefer uncontrolled mode for straightforward, local interactivity where parent control isn’t required.
  • Always decide a mode for the lifetime of the component. Switching modes at runtime is an anti‑pattern and will produce a development warning.

Edge cases and tips

  • defaultProp is only read once (initial render of uncontrolled mode). Updating defaultProp later does not reset the state.
  • In controlled mode, onChange should commit the new value to the parent state; otherwise the UI will not update.
  • When the “next” value equals the current value, onChange will not be called (preventing unnecessary re-renders).

Import

import { useControllableState } from '@loke/ui/use-controllable-state';