useControllableState
Unify a value that can be either controlled or uncontrolled, which is the common requirement of form and overlay components.
API
interface UseControllableStateOptions<T> {
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
}
function useControllableState<T>(
options: UseControllableStateOptions<T> & { defaultValue: T },
): [state: T, setState: (nextState: T | ((prevState: T) => T)) => void];
function useControllableState<T>(
options: UseControllableStateOptions<T> & { value: T },
): [state: T, setState: (nextState: T | ((prevState: T) => T)) => void];
function useControllableState<T>(
options?: UseControllableStateOptions<T>,
): [
state: T | undefined,
setState: (nextState: T | undefined | ((prevState: T | undefined) => T | undefined)) => void,
];
Parameters:
options: Options object.options.value: The controlled value. The state is controlled whenevervalueis notundefined. Keep it defined for the whole lifetime of the component; falling back toundefinedturns the state into uncontrolled.options.defaultValue: The initial value used while the state is uncontrolled. It is ignored whenvalueis provided, and later changes to it do not reset the state.options.onChange: Called with the next value once it actually changes, in both controlled and uncontrolled modes.
Results:
state: The current value:valuewhen controlled, the internal state otherwise.setState: Accepts a new value or an updater function computed from the current value. It callsonChangeonly when the value actually changes and keeps a stable identity.
Notes
- A controlled component must update
valueinonChange; otherwise the state never changes, becausesetStatedoes not touch the internal state while controlled. valueis compared with the previous one usingObject.is, so setting an unchanged value does not callonChange.- Switching between controlled and uncontrolled is supported: the state resumes from the latest value of the previous mode, starting with the very first render after the switch. Prefer keeping a component in one mode for its whole lifetime.
undefinedalways means uncontrolled, so it cannot be a controlled value. Use a sentinel such asnullwhen a controlled value has to represent "empty".- To get
state: Tinstead ofT | undefined, makedefaultValuenon-optional in the type, for example by destructuring it with a default ({ value, defaultValue = "" }). Otherwise the last overload is selected andundefinedis included in the result type.
Example
The following example renders the same field component in both controlled and uncontrolled modes: the controlled field follows the parent state, while the uncontrolled one keeps its own state.
Controlled:
Uncontrolled:
Controlled value: Controlled value
import { useState } from "react";
import { useControllableState } from "@lilib/hooks";
function Field({
value,
defaultValue = "",
onChange,
}: {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}) {
const [text, setText] = useControllableState({ value, defaultValue, onChange });
return <input value={text} onChange={(event) => setText(event.target.value)} />;
}
function Example() {
const [value, setValue] = useState("Controlled value");
return (
<>
<div>
Controlled: <Field value={value} onChange={setValue} />{" "}
<button onClick={() => setValue("Reset from outside")}>Reset</button>
</div>
<div style={{ marginTop: 8 }}>
Uncontrolled: <Field defaultValue="Initial value" />
</div>
<div style={{ marginTop: 8 }}>Controlled value: {value}</div>
</>
);
}
export default Example;