useControllableState
One implementation of controlled and uncontrolled values for form controls.
Primitivestate
Style
Installation
$
npx nativecn-cli@latest add use-controllable-stateProps
| Prop | Type and description |
|---|---|
useControllableState({ value, defaultValue, onChange }) | [value, setValue]: the current value and a stable setter that accepts a value or an updater |
value | T | undefined: anything but undefined makes the state controlled |
defaultValue | T: the starting value while uncontrolled |
onChange | (value: T) => void: called whenever the setter changes the value, in both modes |
Notes
Controlled: the returned value is always value and the setter only calls onChange. Uncontrolled: the hook holds the value from defaultValue and still calls onChange. Setting the same value (Object.is) does nothing. Warns in development when a component switches between controlled and uncontrolled. Every nativecn form control uses it, so each works with value + change handler or defaultValue.
Source
The Vega Style's code, exactly as add copies it into your app.
{hooks}/use-controllable-state.ts
import { useCallback, useEffect, useRef, useState } from "react";
export type UseControllableStateParams<T> = {
/** The controlled value. Passing anything other than `undefined` makes the state controlled. */
value?: T;
/** The starting value while uncontrolled. */
defaultValue: T;
/** Called with the next value whenever the setter changes it (in both modes). */
onChange?: (value: T) => void;
};
export type SetControllableState<T> = (next: T | ((prev: T) => T)) => void;
/**
* One implementation of controlled and uncontrolled values, used by every form control.
*
* - Controlled (`value` is not `undefined`): the returned value is always `value`; the setter
* only calls `onChange`, and the parent decides whether to apply it.
* - Uncontrolled: the hook holds the value, starting at `defaultValue`, and also calls `onChange`.
*
* The setter is stable for the life of the component, accepts an updater function, and does
* nothing when the next value is the same as the current one (`Object.is`).
* In development it warns if the component switches between controlled and uncontrolled.
*/
export function useControllableState<T>({
value,
defaultValue,
onChange,
}: UseControllableStateParams<T>): [T, SetControllableState<T>] {
const [internal, setInternal] = useState<T>(defaultValue);
const controlled = value !== undefined;
const current = controlled ? (value as T) : internal;
// Latest values for the stable setter. While uncontrolled, `currentRef` is also advanced by the
// setter itself so several calls in one event compose (an updater sees the previous result).
// While controlled it only follows `value`, because the parent may reject the change.
const currentRef = useRef(current);
const controlledRef = useRef(controlled);
const onChangeRef = useRef(onChange);
useEffect(() => {
currentRef.current = current;
controlledRef.current = controlled;
onChangeRef.current = onChange;
});
const wasControlled = useRef(controlled);
useEffect(() => {
if (__DEV__ && wasControlled.current !== controlled) {
const from = wasControlled.current ? "controlled" : "uncontrolled";
const to = controlled ? "controlled" : "uncontrolled";
console.warn(
`useControllableState: a component changed from ${from} to ${to}. ` +
"Decide between a controlled value (`value` plus a change handler) and an uncontrolled " +
"one (`defaultValue`) for the lifetime of the component; `value` must not become undefined.",
);
}
wasControlled.current = controlled;
}, [controlled]);
const setValue = useCallback<SetControllableState<T>>((next) => {
const prev = currentRef.current;
const resolved = typeof next === "function" ? (next as (prev: T) => T)(prev) : (next as T);
if (Object.is(resolved, prev)) return;
if (!controlledRef.current) {
currentRef.current = resolved;
setInternal(resolved);
}
onChangeRef.current?.(resolved);
}, []);
return [current, setValue];
}{hooks}/use-controllable-state.ts
import { useCallback, useEffect, useRef, useState } from "react";
export type UseControllableStateParams<T> = {
/** The controlled value. Passing anything other than `undefined` makes the state controlled. */
value?: T;
/** The starting value while uncontrolled. */
defaultValue: T;
/** Called with the next value whenever the setter changes it (in both modes). */
onChange?: (value: T) => void;
};
export type SetControllableState<T> = (next: T | ((prev: T) => T)) => void;
/**
* One implementation of controlled and uncontrolled values, used by every form control.
*
* - Controlled (`value` is not `undefined`): the returned value is always `value`; the setter
* only calls `onChange`, and the parent decides whether to apply it.
* - Uncontrolled: the hook holds the value, starting at `defaultValue`, and also calls `onChange`.
*
* The setter is stable for the life of the component, accepts an updater function, and does
* nothing when the next value is the same as the current one (`Object.is`).
* In development it warns if the component switches between controlled and uncontrolled.
*/
export function useControllableState<T>({
value,
defaultValue,
onChange,
}: UseControllableStateParams<T>): [T, SetControllableState<T>] {
const [internal, setInternal] = useState<T>(defaultValue);
const controlled = value !== undefined;
const current = controlled ? (value as T) : internal;
// Latest values for the stable setter. While uncontrolled, `currentRef` is also advanced by the
// setter itself so several calls in one event compose (an updater sees the previous result).
// While controlled it only follows `value`, because the parent may reject the change.
const currentRef = useRef(current);
const controlledRef = useRef(controlled);
const onChangeRef = useRef(onChange);
useEffect(() => {
currentRef.current = current;
controlledRef.current = controlled;
onChangeRef.current = onChange;
});
const wasControlled = useRef(controlled);
useEffect(() => {
if (__DEV__ && wasControlled.current !== controlled) {
const from = wasControlled.current ? "controlled" : "uncontrolled";
const to = controlled ? "controlled" : "uncontrolled";
console.warn(
`useControllableState: a component changed from ${from} to ${to}. ` +
"Decide between a controlled value (`value` plus a change handler) and an uncontrolled " +
"one (`defaultValue`) for the lifetime of the component; `value` must not become undefined.",
);
}
wasControlled.current = controlled;
}, [controlled]);
const setValue = useCallback<SetControllableState<T>>((next) => {
const prev = currentRef.current;
const resolved = typeof next === "function" ? (next as (prev: T) => T)(prev) : (next as T);
if (Object.is(resolved, prev)) return;
if (!controlledRef.current) {
currentRef.current = resolved;
setInternal(resolved);
}
onChangeRef.current?.(resolved);
}, []);
return [current, setValue];
}
