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
propand handles updates viaonChange. - 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
propis provided (notundefined): the hook is in controlled mode.- The returned
valueequalsprop. setValue(next)invokesonChange(nextResolved)if the next value differs from the currentprop.
- The returned
- When
propis not provided: the hook is in uncontrolled mode.- The hook stores internal state initialized from
defaultProp. setValue(next)updates internal state and then callsonChange(updated).
- The hook stores internal state initialized from
- In development, if a component switches between controlled and uncontrolled at runtime, the hook logs a warning with the
callerlabel (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
defaultPropis only read once (initial render of uncontrolled mode). UpdatingdefaultProplater does not reset the state.- In controlled mode,
onChangeshould commit the new value to the parent state; otherwise the UI will not update. - When the “next” value equals the current value,
onChangewill not be called (preventing unnecessary re-renders).
Import
import { useControllableState } from '@loke/ui/use-controllable-state';