Building flexible components that work in both controlled and uncontrolled modes is a hallmark of professional component libraries. Rather than implementing this complex logic ourselves, we can leverage Radix UI's battle-tested @radix-ui/react-use-controllable-state
hook to handle this sophisticated state management pattern with confidence.
Installing the Radix Hook
The @radix-ui/react-use-controllable-state
hook is a standalone package that provides robust controllable state management:
npm i @radix-ui/react-use-controllable-state
This lightweight hook (~2kb) gives you the same state management patterns used internally by Radix UI's component library, ensuring your components behave consistently with industry standards.
Why Use Radix's Hook?
The @radix-ui/react-use-controllable-state
hook provides:
- Battle-tested reliability: Used in production by thousands of applications
- Consistent API: Follows established patterns from Radix UI
- Edge case handling: Manages complex scenarios like prop changes and initial values
- TypeScript support: Full type safety out of the box
- Performance optimized: Minimal re-renders and efficient state updates
Basic Hook Usage
The hook accepts three main parameters and returns a tuple with the current value and setter:
import { useControllableState } from '@radix-ui/react-use-controllable-state';
function MyComponent({
value: controlledValue,
defaultValue,
onValueChange,
}) {
const [value, setValue] = useControllableState({
prop: controlledValue, // The controlled value prop
defaultProp: defaultValue, // Default value for uncontrolled mode
onChange: onValueChange, // Called when value changes
});
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
The hook automatically detects whether the component is in controlled or uncontrolled mode based on whether the prop
is defined.
Building a Dropdown Component
Let's create a simpler dropdown component that demonstrates the key benefits of controllable state:
'use client';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { ChevronDownIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
interface DropdownProps {
options: { label: string; value: string }[];
placeholder?: string;
// Controllable value state
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
// Controllable open state
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function Dropdown({
options,
placeholder = 'Select an option...',
value: controlledValue,
defaultValue = '',
onValueChange,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
}: DropdownProps) {
// Two pieces of controllable state using the Radix hook
const [selectedValue, setSelectedValue] = useControllableState({
prop: controlledValue,
defaultProp: defaultValue,
onChange: onValueChange,
});
const [isOpen, setIsOpen] = useControllableState({
prop: controlledOpen,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const selectedOption = options.find(option => option.value === selectedValue);
return (
<div className="relative">
<Button
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className="w-full justify-between"
>
{selectedOption?.label || placeholder}
<ChevronDownIcon className="h-4 w-4" />
</Button>
{isOpen && (
<div className="absolute top-full left-0 w-full mt-1 border rounded-md bg-white shadow-lg z-10">
{options.map((option) => (
<button
key={option.value}
className="w-full text-left px-3 py-2 hover:bg-gray-100 first:rounded-t-md last:rounded-b-md"
onClick={() => {
setSelectedValue(option.value);
setIsOpen(false);
}}
>
{option.label}
</button>
))}
</div>
)}
</div>
);
}
Usage Patterns and Examples
The combobox component above demonstrates multiple controllable state patterns:
Uncontrolled Usage
This is the uncontrolled usage of the combobox component, meaning the component manages its own state.
function UncontrolledExample() {
return (
<Combobox
data={[
{ label: 'Option 1', value: 'opt1' },
{ label: 'Option 2', value: 'opt2' },
]}
type="option"
defaultValue="opt1"
>
<ComboboxTrigger />
<ComboboxContent>
<ComboboxInput />
<ComboboxList>
<ComboboxEmpty />
<ComboboxGroup>
{data.map((item) => (
<ComboboxItem key={item.value} value={item.value}>
{item.label}
</ComboboxItem>
))}
</ComboboxGroup>
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
Controlled Usage
This is the controlled usage of the combobox component, meaning the parent manages the state through the use of props.
function ControlledExample() {
const [value, setValue] = useState('');
const [open, setOpen] = useState(false);
return (
<Combobox
data={options}
type="option"
value={value}
onValueChange={setValue}
open={open}
onOpenChange={setOpen}
>
{/* Same child components */}
</Combobox>
);
}
Hybrid Usage
This is a hybrid usage of the combobox component, meaning some state is controlled and some is uncontrolled.
function HybridExample() {
const [value, setValue] = useState('');
// Open state is uncontrolled, value is controlled
return (
<Combobox
data={options}
type="option"
value={value}
onValueChange={setValue}
defaultOpen={false}
>
{/* Child components */}
</Combobox>
);
}
Building a Simple Toggle Component
Let's create a simpler example to demonstrate the basic pattern:
'use client';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
interface ToggleProps {
id?: string;
label?: string;
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
disabled?: boolean;
}
export function Toggle({
id,
label,
checked: controlledChecked,
defaultChecked = false,
onCheckedChange,
disabled = false,
}: ToggleProps) {
const [checked, setChecked] = useControllableState({
prop: controlledChecked,
defaultProp: defaultChecked,
onChange: onCheckedChange,
});
return (
<div className="flex items-center space-x-2">
<Switch
id={id}
checked={checked}
onCheckedChange={setChecked}
disabled={disabled}
/>
{label && (
<Label
htmlFor={id}
className={disabled ? 'text-muted-foreground' : ''}
>
{label}
</Label>
)}
</div>
);
}
// Usage examples:
// Uncontrolled
<Toggle label="Enable notifications" defaultChecked={true} />
// Controlled
function App() {
const [notifications, setNotifications] = useState(false);
return (
<Toggle
label="Enable notifications"
checked={notifications}
onCheckedChange={setNotifications}
/>
);
}
Think about a component in your current project that could benefit from controllable state. What pieces of state should be controllable? How would you design the API to feel natural for both controlled and uncontrolled usage? Consider validation, error states, and complex interactions.
Best Practices and Common Patterns
1. Prop Naming Convention
Follow the established React convention for controllable props:
interface ComponentProps {
// For boolean state
checked?: boolean; // Controlled value
defaultChecked?: boolean; // Uncontrolled default
onCheckedChange?: (checked: boolean) => void;
// For string/other state
value?: string; // Controlled value
defaultValue?: string; // Uncontrolled default
onValueChange?: (value: string) => void;
// For open/close state
open?: boolean; // Controlled value
defaultOpen?: boolean; // Uncontrolled default
onOpenChange?: (open: boolean) => void;
}
2. Multiple Controllable States
Some components need multiple controllable states:
function MultiStateComponent({
// Selected value
value,
defaultValue,
onValueChange,
// Open state
open,
defaultOpen,
onOpenChange,
// Search input
searchValue,
defaultSearchValue,
onSearchValueChange,
}) {
const [selectedValue, setSelectedValue] = useControllableState({
prop: value,
defaultProp: defaultValue,
onChange: onValueChange,
});
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [search, setSearch] = useControllableState({
prop: searchValue,
defaultProp: defaultSearchValue,
onChange: onSearchValueChange,
});
// Component implementation...
}
3. TypeScript Support
The Radix hook provides excellent TypeScript support:
interface TypedToggleProps<T = boolean> {
value?: T;
defaultValue?: T;
onValueChange?: (value: T) => void;
}
function TypedToggle<T = boolean>({
value,
defaultValue,
onValueChange,
}: TypedToggleProps<T>) {
const [state, setState] = useControllableState<T>({
prop: value,
defaultProp: defaultValue,
onChange: onValueChange,
});
// Full type safety maintained
return <div>{/* Implementation */}</div>;
}
The @radix-ui/react-use-controllable-state
hook provides a robust, battle-tested foundation for building flexible shadcn components. By leveraging this hook, you can create components that seamlessly adapt between controlled and uncontrolled modes while maintaining consistent behavior and excellent developer experience.
Key benefits of using this approach:
- Reliability: Proven in production across thousands of applications
- Consistency: Follows established React patterns and conventions
- Flexibility: Easy transition between controlled and uncontrolled modes
- Performance: Optimized to minimize unnecessary re-renders
- TypeScript: Full type safety and excellent developer experience
Was this helpful?