@knitui/hooks
useUncontrolled
Controlled/uncontrolled state for an ARBITRARY value — port of Mantine's useUncontrolled. Generalizes the boolean-only internal/use-toggle: when value is provided the state is controlled (the setter just forwards to onChange); otherwise it is tracked internally, seeded from defaultValue then finalValue. Precisely generic — no any. The returned setter is REFERENTIALLY STABLE in both the controlled and the uncontrolled branch, and that stability is load-bearing rather than a nicety. ~48 files across components, dates and sheet call this hook and hand the setter straight to a child, a context value, or a useMemo/useEffect dep list. When the setter changed identity every render, the invalidation cascaded: in Combobox a fresh setOpen made the use-combobox store a new object, which made the ComboboxContext value a new object — and because context propagation walks *past* React.memo, every option row re-rendered on every keystroke no matter how carefully it was memoized. Callers almost always pass onChange as an inline arrow, so the latest version is read through a ref (useCallbackRef) instead of being closed over. That also means a consumer who memoized a handler with [] deps around the setter still invokes the current onChange rather than the one from its first render.
Import
import { useUncontrolled } from "@knitui/hooks";Signature
export function useUncontrolled<T>({ value, defaultValue, finalValue, onChange, }: UseUncontrolledOptions<T>): UseUncontrolledReturnValue<T>Exported types: UseUncontrolledOptions, UseUncontrolledReturnValue
useUncontrolled is the state bridge nearly every interactive component in the
kit sits on. It returns [value, setValue, controlled] and decides the mode on
one test, re-evaluated every render: if the value option is not undefined the
state is controlled — setValue only forwards to onChange, and you always
get back the value you passed, so the owner decides when it moves. Otherwise the
state is uncontrolled: tracked internally, seeded once from defaultValue or
from finalValue when defaultValue is absent, with setValue updating that
internal state and calling onChange. An explicit value={undefined} therefore
reads as uncontrolled, which is what lets <Foo value={maybeUndefined} /> fall
back to its own state instead of locking at undefined.
The returned setter is referentially stable in both branches, and that is
load-bearing. Around 48 call sites hand it straight to a child, a context value
or a dependency array; a setter that changed identity each render cascaded — in
Combobox a fresh setOpen produced a new store object, then a new context
value, and context propagation walks past React.memo, so every option row
re-rendered on each keystroke.
When to use it
- Any component that should work both as
<Foo value onChange />and as<Foo defaultValue />without branching on which one the caller used. - Wrapping a kit component and forwarding its controlled props through your own.
- Anywhere you need to know which mode you are in — the third tuple element is that flag.
Notes
onChangefires in both modes and is read throughuseCallbackRef, so the inline arrow callers almost always pass does not destabilise the setter, and a handler memoized with empty deps still invokes the currentonChangerather than the one from its first render.- Internal state is not synced while controlled. A component that switches from controlled to uncontrolled mid-life resumes from the last uncontrolled value, not from the controlled one it was just showing — pick a mode per instance and stay in it.
defaultValuewins overfinalValuewhen both are passed.finalValueis there so a component can supply its own fallback — an empty string, an empty array — without stepping on the caller'sdefaultValue.