← Back to DevBytes

State Management in Radix UI: Patterns and Libraries

Introduction to State Management in Radix UI

Radix UI is a popular, unstyled library of accessible UI primitives for React. It handles complex accessibility concerns, keyboard navigation, and focus management out of the box. However, building a fully functional application requires managing the state of these components effectively. This tutorial explores the state management patterns native to Radix UI and how to integrate them with external state management libraries.

What is State Management in Radix UI?

State management in Radix UI refers to how the internal state of components—such as whether a Dialog is open, which Tab is active, or which Accordion item is expanded—is tracked and updated. Radix UI is designed to be flexible, allowing developers to let Radix handle the state internally (uncontrolled) or take full control of the state themselves (controlled).

Why Does It Matter?

Understanding Radix UI's state management patterns is crucial for several reasons:

Uncontrolled vs. Controlled State Patterns

Like many modern React libraries, Radix UI supports both uncontrolled and controlled component patterns. Choosing the right pattern depends on whether you need to programmatically access or modify the component's state from outside the component itself.

Uncontrolled State (Default)

In an uncontrolled pattern, Radix UI manages the component's state internally. You can provide a default value to set the initial state, but you do not actively track changes. This is ideal for simple use cases where the parent component doesn't need to know the current state.

Here is an example of an uncontrolled Accordion where the first item is open by default:

import * as Accordion from '@radix-ui/react-accordion';

export default function UncontrolledAccordion() {
  return (
    <Accordion.Root defaultValue="item-1">
      <Accordion.Item value="item-1">
        <Accordion.Trigger>Item 1</Accordion.Trigger>
        <Accordion.Content>
          This content is visible by default.
        </Accordion.Content>
      </Accordion.Item>
      <Accordion.Item value="item-2">
        <Accordion.Trigger>Item 2</Accordion.Trigger>
        <Accordion.Content>
          This content is hidden by default.
        </Accordion.Content>
      </Accordion.Item>
    </Accordion.Root>
  );
}

Controlled State

When you need to know the current state of a component—for example, to trigger a data fetch when a Dialog opens, or to close a Popover when a form submits—you must use a controlled pattern. You achieve this by passing the open (or value) prop and an onOpenChange (or onValueChange) callback.

Here is an example of a controlled Dialog using React's built-in useState hook:

import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';

export default function ControlledDialog() {
  const [open, setOpen] = useState(false);

  const handleOpenChange = (isOpen) => {
    console.log('Dialog is now:', isOpen ? 'open' : 'closed');
    setOpen(isOpen);
  };

  return (
    <Dialog.Root open={open} onOpenChange={handleOpenChange}>
      <Dialog.Trigger>Open Dialog</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <Dialog.Title>Controlled Dialog</Dialog.Title>
          <Dialog.Description>
            We are tracking the open state manually.
          </Dialog.Description>
          <button onClick={() => setOpen(false)}>Close Manually</button>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Integrating External State Management Libraries

As your application scales, you might need to manage UI state globally. For instance, you might want a button in the header to open a side navigation drawer rendered at the root level. In these cases, you can connect Radix UI's controlled pattern to external state management libraries.

Using React Context for Global UI State

If you only need to share UI state between a few components that aren't deeply nested, React Context is a lightweight solution. You can wrap your Radix components in a Context Provider and pass the state down.

import * as Dialog from '@radix-ui/react-dialog';
import { createContext, useContext, useState } from 'react';

const DialogContext = createContext();

export function DialogProvider({ children }) {
  const [open, setOpen] = useState(false);
  return (
    <DialogContext.Provider value={{ open, setOpen }}>
      {children}
    </DialogContext.Provider>
  );
}

export function GlobalDialog() {
  const { open, setOpen } = useContext(DialogContext);
  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Portal>
        <Dialog.Content>
          <Dialog.Title>Context Managed Dialog</Dialog.Title>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

export function TriggerButton() {
  const { setOpen } = useContext(DialogContext);
  return <button onClick={() => setOpen(true)}>Open Global Dialog</button>;
}

Integrating with Zustand

For more complex applications, using a dedicated state management library like Zustand can prevent prop drilling and keep your components clean. Zustand allows you to create a store outside the React component tree, making it easy to control Radix UI components from anywhere in your app.

Here is how you can wire up a Radix UI Dialog to a Zustand store:

import * as Dialog from '@radix-ui/react-dialog';
import { create } from 'zustand';

// 1. Create the Zustand store
const useUIStore = create((set) => ({
  isDialogOpen: false,
  setDialogOpen: (isOpen) => set({ isDialogOpen: isOpen }),
}));

export default function ZustandDialog() {
  // 2. Pull state and actions from the store
  const isDialogOpen = useUIStore((state) => state.isDialogOpen);
  const setDialogOpen = useUIStore((state) => state.setDialogOpen);

  return (
    <div>
      <button onClick={() => setDialogOpen(true)}>
        Open Dialog from Anywhere
      </button>

      {/* 3. Connect the store to Radix UI's controlled props */}
      <Dialog.Root open={isDialogOpen} onOpenChange={setDialogOpen}>
        <Dialog.Portal>
          <Dialog.Overlay />
          <Dialog.Content>
            <Dialog.Title>Zustand Managed Dialog</Dialog.Title>
            <p>This dialog's state is managed globally via Zustand.</p>
            <Dialog.Close>Close</Dialog.Close>
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    </div>
  );
}

Best Practices for Radix UI State Management

To get the most out of Radix UI and ensure your application remains accessible and maintainable, follow these best practices:

Conclusion

Radix UI provides a robust and flexible foundation for building accessible components, and its dual support for uncontrolled and controlled state patterns makes it adaptable to any architecture. By starting with internal state and seamlessly transitioning to controlled state using React hooks, Context, or libraries like Zustand, you can build complex, interactive UIs without sacrificing accessibility. Remember to keep state as local as possible, respect Radix's internal state contracts, and leverage external libraries only when global UI state is truly required.

🛠 Tools from DevBytes

Inventory Tracker Pro — Excel inventory system, low-stock alerts · $19
AI Dev Kit for Mac — local AI dev environment templates · $9.99
KeyMapper for Mac — custom keyboard shortcut toolkit · $7.99

← Back to all articles