useControllableState
Back a value-bearing component with either a controlled
value prop or internal state, decided by one call.useControllableState replaces the hand-rolled isControlled + internalValue + sync-effect trio with one call. A component is controlled when its value prop is anything other than undefined — the parent owns the value and the hook never writes internal state. Otherwise it is uncontrolled, and the hook keeps the value in useState, seeded from defaultValue (falling back to finalValue).The same
StarPicker is rendered twice below: once with only defaultValue, once with value + onChange.Signature
const [value, setValue, isControlled] = useControllableState({
value: props.value, // controlled value (undefined ⇒ uncontrolled)
defaultValue: props.defaultValue,
finalValue: 0, // fallback when neither is provided
onChange: props.onChange,
});Behavior
onChangefires synchronously from your event handler in both modes — not from an effect — so callbacks land before paint.setValueaccepts a value or an updater function, likeuseState. Several updater calls inside one handler compose against each other.- Extra arguments are forwarded to
onChangeafter the value:setValue(next, { source: 'keyboard' })callsonChange(next, { source: 'keyboard' }). setValueis referentially stable for the component's lifetime — safe in dependency arrays and memoized context values.Switching controlled → uncontrolled mid-life seeds internal state with the last controlled value so the UI holds its position. Any mode switch logs a warning in development.
See also
useDisclosure— the boolean-only case, withopen/close/toggleuseDebouncedValue— debounce a value before acting on it
Loading demo…
import { useState } from 'react';
import { Badge, Block, Button, Rating, Row, Text, useControllableState } from '@plocks/ui';
interface StarPickerProps {
/** Controlled value. Passing this hands ownership to the parent. */
value?: number;
/** Initial value while uncontrolled. */
defaultValue?: number;
onChange?: (value: number) => void;
}
/** One component that supports both modes — the hook decides which is active. */
function StarPicker({ value, defaultValue, onChange }: StarPickerProps) {
const [rating, setRating, isControlled] = useControllableState({
value,
defaultValue,
finalValue: 0,
onChange
});
return (
<Row gap="sm" align="center">
<Rating value={rating} onChange={setRating} />
<Badge variant="light" c={isControlled ? 'primary' : 'gray'}>
{isControlled ? 'controlled' : 'uncontrolled'}
</Badge>
</Row>
);
}
export function Demo() {
const [rating, setRating] = useState(3);
return (
<Block gap="lg">
<Block gap="xs">
<Text size="sm" c="muted">No value prop — the hook keeps the rating in internal state.</Text>
<StarPicker defaultValue={2} />
</Block>
<Block gap="xs">
<Text size="sm" c="muted">A value prop — the parent owns the rating, so it can drive it too.</Text>
<StarPicker value={rating} onChange={setRating} />
<Row gap="sm" wrap="wrap">
<Button size="sm" variant="outline" onPress={() => setRating(5)}>Set 5 from the parent</Button>
<Button size="sm" variant="ghost" onPress={() => setRating(0)}>Clear</Button>
</Row>
</Block>
</Block>
);
}React Native design for iOS, Android, and Web.
Quick Links
Resources