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

  • onChange fires synchronously from your event handler in both modes — not from an effect — so callbacks land before paint.
  • setValue accepts a value or an updater function, like useState. Several updater calls inside one handler compose against each other.
  • Extra arguments are forwarded to onChange after the value: setValue(next, { source: 'keyboard' }) calls onChange(next, { source: 'keyboard' }).
  • setValue is 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, with open / close / toggle
  • useDebouncedValue — 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> ); }