TIL

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>;
}
// Usage
function 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 mode
onChange?: (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 undefined
useControllableState({
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 mode
const [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 setValue
const setValue = (nextValue: T | ((prev: T) => T)) => {
if (isControlled) {
// Controlled: notify parent
const newValue = isFunction(nextValue) ? nextValue(value) : nextValue;
if (prop !== newValue) {
onChange?.(newValue);
}
} else {
// Uncontrolled: update ourselves
setInternalState(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 value
setValue(true);
// Function updater
setValue((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 mode
const isControlled = prop !== undefined;
// Internal state
const [internalState, setInternalState] = useState(defaultProp);
// Which value to use
const value = isControlled ? prop : internalState;
// Setter function
const 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 state
const [value, setValue] = useState(defaultProp);
const prevValueRef = useRef(value);
// Store onChange in a ref
const onChangeRef = useRef(onChange);
useInsertionEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// Fire onChange when value changes
useEffect(() => {
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 array
useEffect(() => {
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 value
const 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:

  1. Render Phase ↓
  2. useInsertionEffect 🔴 (Before DOM mutations) ↓
  3. DOM Mutations ↓
  4. useLayoutEffect 🟡 (After DOM, before paint) ↓
  5. Browser Paint ↓
  6. 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 useCallback
const setValue = (nextValue) => {
/* ... */
};
// User's code:
useEffect(() => {
// Runs EVERY render! 😱
}, [setValue]);

Solution: Memoize with useCallback

// ✅ With useCallback
const 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 switching
function 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 versions
const 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 management
const [uncontrolledProp, setUncontrolledProp, onChangeRef] = useUncontrolledState({
defaultProp,
onChange,
});
const isControlled = prop !== undefined;
const value = isControlled ? prop : uncontrolledProp;
// Dev warning for mode switching
if (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 routing
const 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!
<Checkbox
defaultChecked={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:

<Checkbox
checked={false}
onChange={(val) => {
console.log('Called!'); // ← Runs even if val === false
trackAnalytics('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

  1. The Pattern One hook supports both modes:
// Uncontrolled
useControllableState({
defaultProp: false,
});
// Controlled
useControllableState({
prop: checked,
onChange: setChecked,
defaultProp: false,
});
  1. The Architecture Two-layer design:

useUncontrolledState: Worker (manages state + onChange) useControllableState: Orchestrator (decides mode + routes)

  1. The Ref Pattern Always call latest onChange:
const onChangeRef = useRef(onChange);
useInsertionEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onChangeRef.current?.(value);
}, [value]);
  1. Performance Optimizations Three key optimizations:

useCallback for setValue stability Comparison check before onChange useInsertionEffect for ref sync timing

  1. 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)

  1. 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 usage
function 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>
<AccordionItem
id="item-1"
isOpen={openItem === 'item-1'}
onToggle={() => setOpenItem((prev) => (prev === 'item-1' ? null : 'item-1'))}
/>
<AccordionItem
id="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.

If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Share on X
Buy me a coffee