Overview
Radix UI components with internal state support both controlled and uncontrolled usage patterns, giving you flexibility in how you manage state.
This follows the same pattern as form elements in React: you can either let the component manage its own state (uncontrolled) or manage it yourself (controlled).
Controlled vs Uncontrolled
Uncontrolled Components
Uncontrolled components manage their own internal state. You provide an initial value, and the component handles updates.
Example: Uncontrolled Accordion
import * as Accordion from '@radix-ui/react-accordion';
function UncontrolledExample() {
return (
<Accordion.Root
type="single"
defaultValue="item-1" // Initial state
collapsible
>
<Accordion.Item value="item-1">
<Accordion.Header>
<Accordion.Trigger>Item 1</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Header>
<Accordion.Trigger>Item 2</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
);
}
The accordion manages which item is open internally. You don’t need to track state.
Controlled Components
Controlled components delegate state management to you. You provide the current value and an onChange handler.
Example: Controlled Accordion
import { useState } from 'react';
import * as Accordion from '@radix-ui/react-accordion';
function ControlledExample() {
const [value, setValue] = useState('item-1');
return (
<>
<p>Currently open: {value}</p>
<Accordion.Root
type="single"
value={value} // You control the state
onValueChange={setValue} // You handle updates
collapsible
>
<Accordion.Item value="item-1">
<Accordion.Header>
<Accordion.Trigger>Item 1</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Header>
<Accordion.Trigger>Item 2</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
<button onClick={() => setValue('item-1')}>Open Item 1</button>
<button onClick={() => setValue('item-2')}>Open Item 2</button>
</>
);
}
You manage the state, so you can read it, update it from anywhere, persist it, sync it with URL params, etc.
Prop Patterns
All stateful Radix components follow this pattern:
| Pattern | Props | Example |
|---|
| Uncontrolled | defaultValue | <Accordion defaultValue="item-1"> |
| Controlled | value + onValueChange | <Accordion value={v} onValueChange={setV}> |
Use uncontrolled for simple cases. Use controlled when you need to read or manipulate state externally.
How It Works Internally
Radix uses the useControllableState hook to support both patterns.
From packages/react/use-controllable-state/src/use-controllable-state.tsx:18:
export function useControllableState<T>({
prop,
defaultProp,
onChange = () => {},
caller,
}: UseControllableStateParams<T>): [T, SetStateFn<T>] {
const [uncontrolledProp, setUncontrolledProp, onChangeRef] = useUncontrolledState({
defaultProp,
onChange,
});
const isControlled = prop !== undefined;
const value = isControlled ? prop : uncontrolledProp;
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];
}
This hook:
- Detects if a
prop value is provided (controlled)
- Uses internal state if no
prop (uncontrolled)
- Calls
onChange in controlled mode
- Warns in development if you switch between modes
Component Examples
Dialog
Uncontrolled:
import * as Dialog from '@radix-ui/react-dialog';
function UncontrolledDialog() {
return (
<Dialog.Root defaultOpen={false}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Dialog</Dialog.Title>
<Dialog.Description>This is a dialog</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Controlled:
import { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
function ControlledDialog() {
const [open, setOpen] = useState(false);
return (
<>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Dialog</Dialog.Title>
<Dialog.Description>This is a dialog</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{/* Control from outside */}
<button onClick={() => setOpen(true)}>Open from outside</button>
</>
);
}
From packages/react/dialog/src/dialog.tsx:61:
const [open, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen ?? false,
onChange: onOpenChange,
caller: DIALOG_NAME,
});
Checkbox
Uncontrolled:
import * as Checkbox from '@radix-ui/react-checkbox';
import { CheckIcon } from '@radix-ui/react-icons';
function UncontrolledCheckbox() {
return (
<Checkbox.Root defaultChecked={false}>
<Checkbox.Indicator>
<CheckIcon />
</Checkbox.Indicator>
</Checkbox.Root>
);
}
Controlled:
import { useState } from 'react';
import * as Checkbox from '@radix-ui/react-checkbox';
import { CheckIcon } from '@radix-ui/react-icons';
function ControlledCheckbox() {
const [checked, setChecked] = useState(false);
return (
<>
<Checkbox.Root checked={checked} onCheckedChange={setChecked}>
<Checkbox.Indicator>
<CheckIcon />
</Checkbox.Indicator>
</Checkbox.Root>
<p>Checkbox is {checked ? 'checked' : 'unchecked'}</p>
<button onClick={() => setChecked(!checked)}>Toggle</button>
</>
);
}
Accordion (Multiple Mode)
Uncontrolled:
import * as Accordion from '@radix-ui/react-accordion';
function UncontrolledMultiple() {
return (
<Accordion.Root
type="multiple"
defaultValue={['item-1', 'item-2']} // Multiple items open by default
>
{/* Items */}
</Accordion.Root>
);
}
Controlled:
import { useState } from 'react';
import * as Accordion from '@radix-ui/react-accordion';
function ControlledMultiple() {
const [value, setValue] = useState(['item-1']);
return (
<>
<Accordion.Root
type="multiple"
value={value}
onValueChange={setValue}
>
{/* Items */}
</Accordion.Root>
<button onClick={() => setValue(['item-1', 'item-2', 'item-3'])}>
Open all
</button>
<button onClick={() => setValue([])}>
Close all
</button>
</>
);
}
From packages/react/accordion/src/accordion.tsx:161:
const [value, setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue ?? [],
onChange: onValueChange,
caller: ACCORDION_NAME,
});
State Change Callbacks
All controlled components provide callbacks that fire when state changes.
Using Callbacks
import { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
function DialogWithCallbacks() {
const [open, setOpen] = useState(false);
const handleOpenChange = (newOpen) => {
console.log('Dialog is now:', newOpen ? 'open' : 'closed');
// Track analytics
if (newOpen) {
analytics.track('dialog_opened');
}
// Update state
setOpen(newOpen);
};
return (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
{/* ... */}
</Dialog.Root>
);
}
Common Use Cases
1. Analytics tracking:
function TrackedAccordion() {
const handleValueChange = (value) => {
analytics.track('accordion_item_toggled', { value });
};
return (
<Accordion.Root
type="single"
defaultValue="item-1"
onValueChange={handleValueChange}
>
{/* ... */}
</Accordion.Root>
);
}
2. Persist to localStorage:
function PersistentAccordion() {
const [value, setValue] = useState(() => {
return localStorage.getItem('accordion-value') || 'item-1';
});
const handleValueChange = (newValue) => {
setValue(newValue);
localStorage.setItem('accordion-value', newValue);
};
return (
<Accordion.Root
type="single"
value={value}
onValueChange={handleValueChange}
collapsible
>
{/* ... */}
</Accordion.Root>
);
}
3. Sync with URL params:
import { useSearchParams } from 'react-router-dom';
function URLSyncedAccordion() {
const [searchParams, setSearchParams] = useSearchParams();
const value = searchParams.get('section') || 'item-1';
const handleValueChange = (newValue) => {
setSearchParams({ section: newValue });
};
return (
<Accordion.Root
type="single"
value={value}
onValueChange={handleValueChange}
collapsible
>
{/* ... */}
</Accordion.Root>
);
}
4. Conditional logic:
function ConditionalDialog() {
const [open, setOpen] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(true);
const handleOpenChange = (newOpen) => {
if (!newOpen && hasUnsavedChanges) {
const confirmed = window.confirm('You have unsaved changes. Close anyway?');
if (!confirmed) return; // Don't close
}
setOpen(newOpen);
};
return (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
{/* ... */}
</Dialog.Root>
);
}
Finite State Machines
Radix components use enumerated strings for state, not booleans.
Why Enums Over Booleans?
❌ Boolean state:
const [isOpen, setIsOpen] = useState(false);
Limitations:
- Only two states
- Doesn’t scale to complex states
- Less explicit
✅ Enumerated state:
type State = 'open' | 'closed';
const [state, setState] = useState<State>('closed');
Benefits:
- Explicit states
- Easy to extend (add ‘opening’, ‘closing’, etc.)
- Self-documenting
- Type-safe
State Attributes
Components expose state via data-state attributes:
<Accordion.Item value="item-1">
{/* data-state="open" or data-state="closed" */}
</Accordion.Item>
From packages/react/accordion/src/accordion.tsx:372:
<CollapsiblePrimitive.Root
data-orientation={accordionContext.orientation}
data-state={getState(open)}
{...collapsibleScope}
{...accordionItemProps}
ref={forwardedRef}
disabled={disabled}
open={open}
onOpenChange={(open) => {
if (open) {
valueContext.onItemOpen(value);
} else {
valueContext.onItemClose(value);
}
}}
/>
Helper function:
function getState(open?: boolean) {
return open ? 'open' : 'closed';
}
Extended States
Some components have richer state:
// Checkbox has three states
<Checkbox.Root checked={true} /> // data-state="checked"
<Checkbox.Root checked={false} /> // data-state="unchecked"
<Checkbox.Root checked="indeterminate" /> // data-state="indeterminate"
Advanced Patterns
Derived State
Compute values from component state:
function AccordionWithDerivedState() {
const [value, setValue] = useState(['item-1']);
// Derived state
const allOpen = value.length === 3;
const allClosed = value.length === 0;
const someOpen = value.length > 0 && value.length < 3;
return (
<>
<div>
<span>Status: </span>
{allOpen && 'All items open'}
{allClosed && 'All items closed'}
{someOpen && `${value.length} items open`}
</div>
<Accordion.Root
type="multiple"
value={value}
onValueChange={setValue}
>
{/* Items */}
</Accordion.Root>
</>
);
}
Coordinated State
Manage multiple components together:
function CoordinatedComponents() {
const [dialogOpen, setDialogOpen] = useState(false);
const [accordionValue, setAccordionValue] = useState('item-1');
// Open dialog when accordion changes
const handleAccordionChange = (newValue) => {
setAccordionValue(newValue);
if (newValue === 'item-3') {
setDialogOpen(true);
}
};
return (
<>
<Accordion.Root
type="single"
value={accordionValue}
onValueChange={handleAccordionChange}
collapsible
>
{/* Items */}
</Accordion.Root>
<Dialog.Root open={dialogOpen} onOpenChange={setDialogOpen}>
{/* Dialog */}
</Dialog.Root>
</>
);
}
State Machines with XState
For complex state logic, integrate with state machine libraries:
import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';
import * as Dialog from '@radix-ui/react-dialog';
const dialogMachine = createMachine({
id: 'dialog',
initial: 'closed',
states: {
closed: {
on: { OPEN: 'open' }
},
open: {
on: {
CLOSE: 'closed',
CONFIRM: 'confirming'
}
},
confirming: {
on: {
SUCCESS: 'closed',
ERROR: 'open'
}
}
}
});
function StateMachineDialog() {
const [state, send] = useMachine(dialogMachine);
const open = state.matches('open') || state.matches('confirming');
const handleOpenChange = (newOpen) => {
if (newOpen) {
send('OPEN');
} else {
send('CLOSE');
}
};
return (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Confirm Action</Dialog.Title>
<button
onClick={async () => {
send('CONFIRM');
try {
await performAction();
send('SUCCESS');
} catch (error) {
send('ERROR');
}
}}
disabled={state.matches('confirming')}
>
{state.matches('confirming') ? 'Loading...' : 'Confirm'}
</button>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Best Practices
1. Choose the Right Pattern
Use uncontrolled by default. Only use controlled when you need external access to state.
Use uncontrolled when:
- Simple interactions
- State doesn’t need to be read elsewhere
- No persistence required
Use controlled when:
- Need to read state
- Sync with other state
- Persist state
- Conditional logic based on state
- Analytics tracking
2. Don’t Switch Between Patterns
Never switch a component from controlled to uncontrolled (or vice versa) during its lifetime.
❌ Avoid:
function BadExample({ shouldControl }) {
const [value, setValue] = useState('item-1');
return (
<Accordion.Root
type="single"
// Switches between controlled and uncontrolled!
{...(shouldControl ? { value, onValueChange: setValue } : { defaultValue: 'item-1' })}
/>
);
}
Radix will warn you in development if you do this.
3. Initialize State Consistently
Ensure default/initial values match:
const INITIAL_VALUE = 'item-1';
function ConsistentExample() {
const [value, setValue] = useState(INITIAL_VALUE);
return (
<Accordion.Root
type="single"
value={value}
onValueChange={setValue}
// Both use the same initial value
/>
);
}
4. Handle Edge Cases in Callbacks
Validate state changes:
function SafeDialog() {
const [open, setOpen] = useState(false);
const [isValid, setIsValid] = useState(true);
const handleOpenChange = (newOpen) => {
// Prevent closing if invalid
if (!newOpen && !isValid) {
alert('Please fix errors before closing');
return;
}
setOpen(newOpen);
};
return (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
{/* ... */}
</Dialog.Root>
);
}
Summary
Radix UI state management provides:
- Flexibility - Choose controlled or uncontrolled
- Consistency - Same pattern across all components
- Callbacks - React to state changes
- Explicit state - Enumerated strings, not booleans
- Type safety - Full TypeScript support
The controlled/uncontrolled pattern gives you the flexibility to start simple and add complexity only when needed.
Related Concepts