useControllableState
A hook that makes any component work controlled or uncontrolled from one state API.
Uncontrolled
0
Owns its state.
Controlled
5
Parent state: 5
Controlled and frozen
42
No `onValueChange`, so the value never moves.
Installation
Usage
Every reusable component eventually needs to work both ways: controlled by a parent through
value / onValueChange, and uncontrolled through defaultValue. This hook collapses both into a
single useState-shaped API, so the component body never branches on which mode it is in.
import { useControllableState } from "@/hooks/use-controllable-state";
function Rating({ value, defaultValue = 0, onValueChange }: RatingProps) {
const [rating, setRating] = useControllableState({
value,
defaultValue,
onChange: onValueChange,
});
return <button onClick={() => setRating((previous) => previous + 1)}>{rating}</button>;
}<Rating defaultValue={3} /> manages its own state. <Rating value={rating} onValueChange={setRating} />
defers to the parent. The component itself does not know the difference.
Behavior
- The setter accepts a value or an updater function, exactly like
useState. onChangefires only when the value actually changes, compared withObject.isby default.- In controlled mode the hook never writes to internal state — if the parent ignores
onChange, the value does not move. That is what makes a controlled component genuinely controlled. - The setter identity is stable, so it is safe in dependency arrays and in memoized children.
Options
| Option | Description |
|---|---|
value | The controlled value. Passing undefined selects uncontrolled mode. |
defaultValue | Initial value when uncontrolled. Accepts a lazy initializer. |
onChange | Called with the next value whenever it changes, in either mode. |
isEqual | Custom equality used to decide whether a change happened. |
Pass isEqual when the value is a structure that is rebuilt on every render:
const [range, setRange] = useControllableState({
value,
defaultValue: null,
onChange: onValueChange,
isEqual: (a, b) => a?.start === b?.start && a?.end === b?.end,
});