Radix UI - useControllableState: From Concept to Production
- 文章發表於
- ...
Building useControllableState: From Concept to Production A deep dive into one of the most fundamental patterns in React component libraries - the controllable state pattern used by Radix UI, React Aria, and other professional component libraries.
Table of Contents
The Problem: Flexible Component APIs Controlled vs Uncontrolled: Core Concepts Mini Implementation: Understanding the Basics Full Implementation: Production-Grade Patterns Architectural Deep Dives Key Takeaways
The Problem When building reusable React components, you face a critical design decision: Who should control the state? The Challenge Consider a simple Checkbox component. Users expect it to work in multiple ways:
// Usage 1: Simple case - component manages itself<Checkbox defaultChecked={false} />// Usage 2: Complex case - parent needs control<Checkbox checked={isChecked} onChange={setIsChecked} />
The Problem:
Building TWO separate components is redundant Users expect ONE component that "just works" both ways Most React libraries get this wrong or make it complicated
The Solution:
Build ONE component that supports BOTH patterns Use the useControllableState hook to handle the complexity Provide a clean, predictable API
Core Concepts What is an Uncontrolled Component? An uncontrolled component manages its own state internally.
function Checkbox({ defaultChecked = false }) {const [checked, setChecked] = useState(defaultChecked);return <button onClick={() => setChecked((c) => !c)}>{checked ? '✅' : '⬜️'}</button>;}// Usage<Checkbox defaultChecked={false} />;
Characteristics:
✅ Component owns the state ✅ Simple to use for basic cases ❌ Parent cannot control or read the state ❌ Can't synchronize with external state
Data Flow: User clicks → setState → Component re-renders → Done ✅
What is a Controlled Component? A controlled component receives its state from the parent.
function Checkbox({ checked, onChange }) {return <button onClick={() => onChange(!checked)}>{checked ? '✅' : '⬜️'}</button>;}// Usagefunction Parent() {const [checked, setChecked] = useState(false);return <Checkbox checked={checked} onChange={setChecked} />;}
Characteristics:
✅ Parent owns the state ✅ Parent can control the value externally ✅ Can synchronize with other state ❌ More boilerplate for simple cases ❌ Parent must provide both value and onChange
Data Flow: User clicks → onChange(newValue) → Parent setState → Parent re-renders → New prop → Component re-renders → Done ✅
The Best of Both Worlds Professional component libraries support BOTH patterns with ONE component:
// Works as uncontrolled<Checkbox defaultChecked={false} />// Works as controlled<Checkbox checked={checked} onChange={setChecked} />// Same component! 🎯
This is achieved with the controllable state pattern.
Mini Implementation Let's build a simplified version to understand the core logic. Step 1: The Interface
interface UseControllableStateParams<T> {prop?: T | undefined; // Controlled value (optional)defaultProp: T; // Default for uncontrolled modeonChange?: (value: T) => void; // Notification callback}function useControllableState<T>(params: UseControllableStateParams<T>): [T, (nextValue: T | ((prev: T) => T)) => void];
Key Design Decisions:
prop is optional - undefined means uncontrolled mode defaultProp is required - always need a starting value onChange is optional but recommended Returns same signature as useState - familiar API
Step 2: Determine the Mode
function useControllableState<T>({ prop, defaultProp, onChange }) {// Step 1: Are we controlled or uncontrolled?const isControlled = prop !== undefined;// ...}
The Check:
prop !== undefined → Controlled mode prop === undefined → Uncontrolled mode
Examples:
// Uncontrolled: prop is undefineduseControllableState({defaultProp: false,// prop is undefined});// Controlled: prop has a value (even if falsy!)useControllableState({defaultProp: false,prop: false, // ← defined, so controlled!});
Step 3: Internal State
function useControllableState<T>({ prop, defaultProp, onChange }) {const isControlled = prop !== undefined;// Step 2: Internal state for uncontrolled modeconst [internalState, setInternalState] = useState(defaultProp);// Step 3: Which value to return?const value = isControlled ? prop : internalState;// ...}
Logic:
Always create internal state (even if controlled) In controlled mode: ignore internal state, use prop In uncontrolled mode: use internal state
Why always create internal state?
React hooks must be called unconditionally Can't conditionally call useState Slight memory overhead, but necessary
Step 4: The Setter Function
function useControllableState<T>({ prop, defaultProp, onChange }) {const isControlled = prop !== undefined;const [internalState, setInternalState] = useState(defaultProp);const value = isControlled ? prop : internalState;// Step 4: Handle setValueconst setValue = (nextValue: T | ((prev: T) => T)) => {if (isControlled) {// Controlled: notify parentconst newValue = isFunction(nextValue) ? nextValue(value) : nextValue;if (prop !== newValue) {onChange?.(newValue);}} else {// Uncontrolled: update ourselvessetInternalState(nextValue);}};return [value, setValue];}
Controlled Mode Logic:
Resolve the new value (handle function updaters) Compare with current prop Only call onChange if different (optimization) Parent updates → new prop comes back → component re-renders
Uncontrolled Mode Logic:
Pass nextValue directly to setState React handles function updaters automatically Component re-renders with new internal state
Step 5: Function Updater Support Why check isFunction? Users can call setValue two ways:
// Direct valuesetValue(true);// Function updatersetValue((prev) => !prev);
In controlled mode, we must resolve the function:
if (isControlled) {const newValue = isFunction(nextValue)? nextValue(value) // ← Call it with current value!: nextValue;onChange?.(newValue);}
Why use value and not prop?
value IS the prop in controlled mode In case prop changes during render (edge case) Consistent with the public API
Complete Mini Version
function isFunction(value: unknown): value is Function {return typeof value === 'function';}interface UseControllableStateParams<T> {prop?: T | undefined;defaultProp: T;onChange?: (value: T) => void;}function useControllableState<T>({prop,defaultProp,onChange,}: UseControllableStateParams<T>): [T, (nextValue: T | ((prev: T) => T)) => void] {// Determine modeconst isControlled = prop !== undefined;// Internal stateconst [internalState, setInternalState] = useState(defaultProp);// Which value to useconst value = isControlled ? prop : internalState;// Setter functionconst setValue = (nextValue: T | ((prev: T) => T)) => {if (isControlled) {const newValue = isFunction(nextValue) ? nextValue(value) : nextValue;if (prop !== newValue) {onChange?.(newValue);}} else {setInternalState(nextValue);}};return [value, setValue];}
What Works:
✅ Controlled mode ✅ Uncontrolled mode ✅ Function updaters ✅ Optimization (skip onChange if value unchanged)
What's Missing:
❌ onChange doesn't fire in uncontrolled mode ❌ No performance optimizations (useCallback) ❌ No dev warnings ❌ Stale closure issues with onChange
Full Implementation Now let's build the production-grade version with all optimizations. The Architecture The full version splits responsibilities: ┌──────────────────────────────────────────┐ │ useControllableState │ │ (Orchestrator - decides mode) │ │ │ │ ┌────────────────────────────────────┐ │ │ │ isControlled? │ │ │ │ Yes → use prop + onChangeRef │ │ │ │ No → use uncontrolledValue │ │ │ └────────────────────────────────────┘ │ │ ↓ │ │ ┌────────────────────────────────────┐ │ │ │ useUncontrolledState │ │ │ │ (Worker - manages state) │ │ │ │ │ │ │ │ • useState() │ │ │ │ • onChangeRef with effect │ │ │ │ • Fires onChange automatically │ │ │ └────────────────────────────────────┘ │ └──────────────────────────────────────────┘
Addition 1: useUncontrolledState Helper Purpose: Manage uncontrolled state AND ensure onChange fires.
function useUncontrolledState<T>({defaultProp,onChange,}: {defaultProp: T;onChange?: (value: T) => void;}): [value: T,setValue: React.Dispatch<React.SetStateAction<T>>,onChangeRef: React.RefObject<((value: T) => void) | undefined>,] {// The stateconst [value, setValue] = useState(defaultProp);const prevValueRef = useRef(value);// Store onChange in a refconst onChangeRef = useRef(onChange);useInsertionEffect(() => {onChangeRef.current = onChange;}, [onChange]);// Fire onChange when value changesuseEffect(() => {if (prevValueRef.current !== value) {onChangeRef.current?.(value);prevValueRef.current = value;}}, [value]);return [value, setValue, onChangeRef];}
Key Features:
Stores onChange in a ref:
Always calls the latest version No stale closures Effect doesn't need onChange in deps
Syncs ref with useInsertionEffect:
Runs before other effects Guarantees ref is updated before it's called No race conditions
Fires onChange in useEffect:
Runs after state update Notifies parent in uncontrolled mode too! Consistent behavior across modes
Addition 2: The Ref Pattern for onChange Problem: Function identity and stale closures
// ❌ Bad: onChange in dependency arrayuseEffect(() => {onChange(value);}, [value, onChange]); // ← Re-runs when onChange changes!// User's code:function Parent() {const handleChange = (val) => console.log(val); // ← New function every render!return <Checkbox onChange={handleChange} />;}
Result: Effect fires unnecessarily, even when value didn't change. Solution: The "Latest Ref" pattern
// ✅ Good: Store in ref, only depend on valueconst onChangeRef = useRef(onChange);useInsertionEffect(() => {onChangeRef.current = onChange; // Sync to latest}, [onChange]);useEffect(() => {onChangeRef.current?.(value); // Call latest version}, [value]); // Only value in deps!
Benefits:
Effect only runs when value changes Always calls latest onChange User doesn't need useCallback Better DX!
Addition 3: Effect Timing - useInsertionEffect React's Effect Phases:
- Render Phase ↓
- useInsertionEffect 🔴 (Before DOM mutations) ↓
- DOM Mutations ↓
- useLayoutEffect 🟡 (After DOM, before paint) ↓
- Browser Paint ↓
- useEffect 🟢 (After paint, async) Why useInsertionEffect for ref sync?
// Same render cycle:useInsertionEffect(() => {onChangeRef.current = newOnChange; // ← Runs FIRST}, [onChange]);useEffect(() => {onChangeRef.current?.(value); // ← Runs SECOND, guaranteed latest!}, [value]);
Guarantee:
Even if both effects run in same render useInsertionEffect runs first Ref is always synced before it's called No race conditions
Fallback:
const useInsertionEffect = React.useInsertionEffect || useLayoutEffect;
React 18+ has useInsertionEffect, older versions fall back to useLayoutEffect.
Addition 4: useCallback Optimization Problem: setValue has new identity every render
// ❌ Without useCallbackconst setValue = (nextValue) => {/* ... */};// User's code:useEffect(() => {// Runs EVERY render! 😱}, [setValue]);
Solution: Memoize with useCallback
// ✅ With useCallbackconst setValue = useCallback((nextValue) => {// ... implementation},[isControlled, prop, setUncontrolledProp, onChangeRef],);// User's code:useEffect(() => {// Only runs when dependencies change! ✅}, [setValue]);
Benefits:
Stable function identity Prevents unnecessary effect runs Better performance in large apps Expected behavior for React users
Addition 5: Dev Mode Warning Problem: Switching modes mid-lifecycle
// ❌ Bad: Mode switchingfunction BadComponent() {const [checked, setChecked] = useState(undefined);return (<><Checkbox checked={checked} onChange={setChecked} /><button onClick={() => setChecked(undefined)}>Clear</button></>);}// Timeline:// Render 1: checked = false → Controlled// Render 2: checked = undefined → Uncontrolled ❌
Solution: Track and warn
if (process.env.NODE_ENV !== 'production') {const isControlledRef = useRef(prop !== undefined);useEffect(() => {const wasControlled = isControlledRef.current;if (wasControlled !== isControlled) {const from = wasControlled ? 'controlled' : 'uncontrolled';const to = isControlled ? 'controlled' : 'uncontrolled';console.warn(`${caller} is changing from ${from} to ${to}. ` +`Components should not switch from controlled to uncontrolled ` +`(or vice versa). Decide between using a controlled or ` +`uncontrolled value for the lifetime of the component.`,);}isControlledRef.current = isControlled;}, [isControlled, caller]);}
Features:
Only runs in development Tracks previous controlled state Clear error message Includes component name (via caller param)
Complete Full Version
import * as React from 'react';// Fallback for older React versionsconst useInsertionEffect = (React as any)['useInsertionEffect'] || React.useLayoutEffect;type ChangeHandler<T> = (state: T) => void;type SetStateFn<T> = React.Dispatch<React.SetStateAction<T>>;interface UseControllableStateParams<T> {prop?: T | undefined;defaultProp: T;onChange?: ChangeHandler<T>;caller?: string;}function useControllableState<T>({prop,defaultProp,onChange = () => {},caller,}: UseControllableStateParams<T>): [T, SetStateFn<T>] {// Use helper hook for uncontrolled state managementconst [uncontrolledProp, setUncontrolledProp, onChangeRef] = useUncontrolledState({defaultProp,onChange,});const isControlled = prop !== undefined;const value = isControlled ? prop : uncontrolledProp;// Dev warning for mode switchingif (process.env.NODE_ENV !== 'production') {const isControlledRef = React.useRef(prop !== undefined);React.useEffect(() => {const wasControlled = isControlledRef.current;if (wasControlled !== isControlled) {const from = wasControlled ? 'controlled' : 'uncontrolled';const to = isControlled ? 'controlled' : 'uncontrolled';console.warn(`${caller} is changing from ${from} to ${to}. Components should not ` +`switch from controlled to uncontrolled (or vice versa). Decide between ` +`using a controlled or uncontrolled value for the lifetime of the component.`,);}isControlledRef.current = isControlled;}, [isControlled, caller]);}// Memoized setter with proper routingconst setValue = React.useCallback<SetStateFn<T>>((nextValue) => {if (isControlled) {const value = isFunction(nextValue) ? nextValue(prop) : nextValue;if (value !== prop) {onChangeRef.current?.(value);}} else {setUncontrolledProp(nextValue);}},[isControlled, prop, setUncontrolledProp, onChangeRef],);return [value, setValue];}function useUncontrolledState<T>({defaultProp,onChange,}: Omit<UseControllableStateParams<T>, 'prop'>): [Value: T,setValue: React.Dispatch<React.SetStateAction<T>>,OnChangeRef: React.RefObject<ChangeHandler<T> | undefined>,] {const [value, setValue] = React.useState(defaultProp);const prevValueRef = React.useRef(value);const onChangeRef = React.useRef(onChange);useInsertionEffect(() => {onChangeRef.current = onChange;}, [onChange]);React.useEffect(() => {if (prevValueRef.current !== value) {onChangeRef.current?.(value);prevValueRef.current = value;}}, [value, prevValueRef]);return [value, setValue, onChangeRef];}function isFunction(value: unknown): value is (...args: any[]) => any {return typeof value === 'function';}export { useControllableState };
Architectural Deep Dives Why Extract useUncontrolledState? Separation of Concerns: useUncontrolledState: "I manage state and fire onChange"
useControllableState: "I decide which mode and route calls" Benefits:
Code Reuse: The onChange ref pattern is used in BOTH modes Single Responsibility: Each hook has ONE clear job onChange in Uncontrolled Mode: Without extraction, easy to forget
The Key Insight:
// Users expect onChange even in uncontrolled mode!<CheckboxdefaultChecked={false}onChange={(val) => console.log('Changed:', val)} // ← Should fire!/>
By extracting the state management, the effect automatically handles this.
Why Check if (value !== prop)? The Comparison Check:
if (value !== prop) {onChange?.(value);}
Reason 1: Prevent Unnecessary Work Even though React bails out when setState receives the same value, the onChange callback still runs its side effects:
<Checkboxchecked={false}onChange={(val) => {console.log('Called!'); // ← Runs even if val === falsetrackAnalytics('checkbox', val); // ← API call even if unchanged!setChecked(val); // ← React bails out here}}/>
Without check:
onChange called with same value Side effects run (logging, analytics, API calls) Then React bails out at setState Wasted work! 😞
With check:
Compare first Skip onChange if same No side effects Clean! 😊
Reason 2: Prevent Infinite Loops
function Parent() {const [checked, setChecked] = useState(false);useEffect(() => {// This could create a loop without the check!}, [checked]);return <Checkbox checked={checked} onChange={setChecked} />;}
Why Both Modes Share onChangeRef? Controlled mode uses the ref from useUncontrolledState:
const [uncontrolledProp, setUncontrolledProp, onChangeRef] = useUncontrolledState({defaultProp,onChange,});// Later, in controlled mode:if (isControlled) {onChangeRef.current?.(value); // ← Uses same ref!}
Benefits:
Single Source of Truth: One ref always has latest onChange No Duplication: Don't need separate ref for controlled mode Consistency: Both modes behave identically Simplicity: Less code, less complexity
Why Function Updater Uses prop in Controlled Mode?
const value = isFunction(nextValue) ? nextValue(prop) : nextValue;// ↑// Why prop here?
The Scenario:
function Parent() {const [checked, setChecked] = useState(true);return <Checkbox checked={checked} onChange={setChecked} />;}// Inside Checkbox:// • prop = true (from parent - source of truth)// • internalState = false (stale, from defaultProp)// User clicks:setValue((c) => !c);
If we used internalState (wrong):
nextValue(internalState)↓(c => !c)(false) // internalState = false↓!false = true↓onChange(true) // Parent already has true!↓Nothing happens ❌
If we use prop (correct):
nextValue(prop)↓(c => !c)(true) // prop = true (actual current value)↓!true = false↓onChange(false) // Parent updates to false↓Works! ✅
The Principle:
In controlled mode, prop is the ONLY source of truth Internal state is irrelevant Function updaters must use the actual current value
Key Takeaways
- The Pattern One hook supports both modes:
// UncontrolleduseControllableState({defaultProp: false,});// ControlleduseControllableState({prop: checked,onChange: setChecked,defaultProp: false,});
- The Architecture Two-layer design:
useUncontrolledState: Worker (manages state + onChange) useControllableState: Orchestrator (decides mode + routes)
- The Ref Pattern Always call latest onChange:
const onChangeRef = useRef(onChange);useInsertionEffect(() => {onChangeRef.current = onChange;}, [onChange]);useEffect(() => {onChangeRef.current?.(value);}, [value]);
- Performance Optimizations Three key optimizations:
useCallback for setValue stability Comparison check before onChange useInsertionEffect for ref sync timing
- Developer Experience Dev-friendly features:
Mode switching warnings (dev only) Component name in error messages (via caller) onChange fires in both modes (consistent) Users don't need useCallback (better DX)
- When to Use Use this pattern when:
Building reusable component libraries Need flexible state management Want to match native HTML behavior (like <input>) Building form components, UI primitives
Don't use when:
Simple internal state is enough Component never needs external control Over-engineering a simple problem
Comparison: Mini vs Full FeatureMiniFullControlled mode✅✅Uncontrolled mode✅✅Function updaters✅✅onChange in uncontrolled❌✅useCallback optimization❌✅Ref pattern for onChange❌✅Dev mode warnings❌✅Effect timing optimization❌✅Production-ready❌✅
Real-World Usage Basic Checkbox
function Checkbox({ checked, defaultChecked, onChange }) {const [isChecked, setIsChecked] = useControllableState({prop: checked,defaultProp: defaultChecked ?? false,onChange,caller: 'Checkbox',});return (<button role="checkbox" aria-checked={isChecked} onClick={() => setIsChecked((c) => !c)}>{isChecked ? '✅' : '⬜️'}</button>);}// Uncontrolled usage<Checkbox defaultChecked={false} />;// Controlled usagefunction App() {const [checked, setChecked] = useState(false);return <Checkbox checked={checked} onChange={setChecked} />;}
Input Component
function Input({ value, defaultValue, onChange }) {const [inputValue, setInputValue] = useControllableState({prop: value,defaultProp: defaultValue ?? '',onChange,caller: 'Input',});return <input value={inputValue} onChange={(e) => setInputValue(e.target.value)} />;}
Accordion Component
function Accordion({ value, defaultValue, onValueChange }) {const [openItem, setOpenItem] = useControllableState({prop: value,defaultProp: defaultValue ?? null,onChange: onValueChange,caller: 'Accordion',});return (<div><AccordionItemid="item-1"isOpen={openItem === 'item-1'}onToggle={() => setOpenItem((prev) => (prev === 'item-1' ? null : 'item-1'))}/><AccordionItemid="item-2"isOpen={openItem === 'item-2'}onToggle={() => setOpenItem((prev) => (prev === 'item-2' ? null : 'item-2'))}/></div>);}
Further Reading Libraries Using This Pattern
Radix UI: https://github.com/radix-ui/primitives React Aria: https://react-spectrum.adobe.com/react-aria/ Reach UI: https://reach.tech/ Chakra UI: https://chakra-ui.com/
Related Concepts
Controlled Components: https://react.dev/learn/sharing-state-between-components Custom Hooks: https://react.dev/learn/reusing-logic-with-custom-hooks Component Design Patterns: https://www.patterns.dev/posts/react-patterns
Conclusion The controllable state pattern is a fundamental building block for professional React component libraries. By understanding both the mini and full implementations, you now have:
Conceptual Understanding: Why and when to use this pattern Implementation Skills: How to build it from scratch Architectural Insight: Design decisions and trade-offs Production Knowledge: Optimizations and edge cases
This pattern appears throughout Radix UI, React Aria, and other professional libraries. Master it, and you'll write more flexible, reusable components that delight your users.
