โ† Back to DevBytes

State Management in Chakra UI: Patterns and Libraries

Introduction to State Management in Chakra UI

Chakra UI is a popular React component library that provides accessible, reusable, and composable components for building modern web applications. While Chakra UI excels at styling and component composition, it does not ship with its own state management solution. Instead, it relies on React's built-in state management capabilities and integrates seamlessly with external state management libraries. Understanding how to manage state effectively within a Chakra UI application is essential for building scalable, maintainable, and performant interfaces.

This tutorial explores the patterns and libraries you can use to manage state in a Chakra UI project. We will cover local component state, lifted shared state, context-based patterns, and integration with popular libraries such as Zustand, Redux Toolkit, Jotai, and React Query. By the end, you will have a clear mental model for choosing the right state management approach for your Chakra UI applications.

What Is State Management in Chakra UI?

State management refers to the way an application stores, updates, and shares data across its component tree. In a Chakra UI application, state typically falls into several categories:

Chakra UI components such as Modal, Drawer, Popover, and Menu often require controlled state. For example, a Modal needs an isOpen boolean and an onClose handler. Managing these pieces of state cleanly is where good patterns matter.

Why State Management Matters

Without a deliberate state management strategy, applications quickly become difficult to reason about. Props drilling, duplicated state, and inconsistent updates lead to bugs and poor performance. A well-structured state management approach provides several benefits:

In Chakra UI applications, where components are highly composable, clean state management ensures that your UI remains responsive and consistent across different views.

Pattern 1: Local Component State with useState

The simplest state management pattern is local component state using React's useState hook. This is ideal for UI state that does not need to be shared, such as a single modal's open/close status.

import { useState } from 'react';
import {
  Modal,
  ModalOverlay,
  ModalContent,
  ModalHeader,
  ModalBody,
  ModalCloseButton,
  Button,
  useDisclosure,
} from '@chakra-ui/react';

function SettingsDialog() {
  const { isOpen, onOpen, onClose } = useDisclosure();

  return (
    <>
      <Button onClick={onOpen}>Open Settings</Button>
      <Modal isOpen={isOpen} onClose={onClose}>
        <ModalOverlay />
        <ModalContent>
          <ModalHeader>Settings</ModalHeader>
          <ModalCloseButton />
          <ModalBody>
            <p>Adjust your preferences here.</p>
          </ModalBody>
        </ModalContent>
      </Modal>
    </>
  );
}

Chakra UI provides the useDisclosure hook, which wraps useState specifically for open/close patterns. It returns isOpen, onOpen, onClose, and onToggle, making it the idiomatic choice for managing disclosure-based components.

When to Use Local State

Pattern 2: Lifting State Up

When multiple Chakra UI components need to share state, the standard React approach is to lift that state to their nearest common ancestor. This keeps the data flow explicit and easy to trace.

import { useState } from 'react';
import { Box, Button, Text, VStack } from '@chakra-ui/react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <VStack spacing={4}>
      <Text fontSize="2xl">Count: {count}</Text>
      <Button colorScheme="blue" onClick={() => setCount(count + 1)}>
        Increment
      </Button>
      <Button colorScheme="red" onClick={() => setCount(0)}>
        Reset
      </Button>
    </VStack>
  );
}

While lifting state works well for small applications, it becomes unwieldy when many deeply nested components need access to the same state. At that point, context or external libraries become more appropriate.

Pattern 3: Context API for Shared UI State

React's Context API is a built-in solution for sharing state across a component tree without props drilling. It is particularly useful in Chakra UI applications for managing theme toggles, user preferences, and authentication status.

import { createContext, useContext, useState, useCallback } from 'react';
import { Button, Box, useColorMode } from '@chakra-ui/react';

const SidebarContext = createContext(null);

function SidebarProvider({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);
  const toggle = useCallback(() => setIsOpen((prev) => !prev), []);

  return (
    <SidebarContext.Provider value={{ isOpen, open, close, toggle }}>
      {children}
    </SidebarContext.Provider>
  );
}

function useSidebar() {
  const ctx = useContext(SidebarContext);
  if (!ctx) throw new Error('useSidebar must be used within SidebarProvider');
  return ctx;
}

function SidebarToggle() {
  const { toggle, isOpen } = useSidebar();
  return (
    <Button onClick={toggle}>
      {isOpen ? 'Close Sidebar' : 'Open Sidebar'}
    </Button>
  );
}

function App() {
  return (
    <SidebarProvider>
      <Box p={4}>
        <SidebarToggle />
      </Box>
    </SidebarProvider>
  );
}

Context is a great fit for low-frequency updates such as theme changes or authentication state. However, when state updates frequently, context can cause unnecessary re-renders because every consumer re-renders whenever the context value changes.

Pattern 4: Zustand for Lightweight Global State

Zustand is a minimal, unopinionated state management library that avoids the boilerplate of Redux while providing a simple API for global state. It works exceptionally well with Chakra UI because it allows components to subscribe to only the slices of state they need.

import { create } from 'zustand';
import { Box, Button, Text, VStack } from '@chakra-ui/react';

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),
  clear: () => set({ items: [] }),
}));

function CartSummary() {
  const items = useCartStore((state) => state.items);
  const total = items.reduce((sum, item) => sum + item.price, 0);

  return (
    <Text>Total: ${total.toFixed(2)} ({items.length} items)</Text>
  );
}

function AddProductButton() {
  const addItem = useCartStore((state) => state.addItem);

  return (
    <Button
      colorScheme="green"
      onClick={() =>
        addItem({ id: Date.now(), name: 'Widget', price: 9.99 })
      }
    >
      Add Widget
    </Button>
  );
}

function Cart() {
  return (
    <VStack spacing={4}>
      <CartSummary />
      <AddProductButton />
    </VStack>
  );
}

Notice how CartSummary subscribes only to items, while AddProductButton subscribes only to addItem. This selective subscription prevents unnecessary re-renders and keeps the UI performant.

Pattern 5: Redux Toolkit for Complex Applications

For large-scale applications with complex state interactions, Redux Toolkit (RTK) remains a robust choice. It provides predictable state updates, middleware support, and excellent developer tooling. Integrating RTK with Chakra UI follows the same patterns as any React application.

import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';
import { Button, Text, VStack } from '@chakra-ui/react';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    reset: (state) => {
      state.value = 0;
    },
  },
});

const { increment, decrement, reset } = counterSlice.actions;

const store = configureStore({
  reducer: { counter: counterSlice.reducer },
});

function CounterDisplay() {
  const value = useSelector((state) => state.counter.value);
  return <Text fontSize="3xl">{value}</Text>;
}

function CounterControls() {
  const dispatch = useDispatch();
  return (
    <VStack>
      <Button colorScheme="green" onClick={() => dispatch(increment())}>
        +
      </Button>
      <Button colorScheme="red" onClick={() => dispatch(decrement())}>
        -
      </Button>
      <Button onClick={() => dispatch(reset())}>Reset</Button>
    </VStack>
  );
}

function App() {
  return (
    <Provider store={store}>
      <VStack spacing={4} p={8}>
        <CounterDisplay />
        <CounterControls />
      </VStack>
    </Provider>
  );
}

Redux Toolkit is best suited for applications where you need strict state immutability guarantees, time-travel debugging, or complex asynchronous flows with middleware such as RTK Query.

Pattern 6: Jotai for Atomic State

Jotai takes a different approach from Redux by using an atomic model. Instead of a single store, state is broken into small, independent atoms that components can subscribe to individually. This model pairs naturally with Chakra UI's component-centric philosophy.

import { atom, useAtom } from 'jotai';
import { Input, Text, Box } from '@chakra-ui/react';

const usernameAtom = atom('');

function UsernameInput() {
  const [username, setUsername] = useAtom(usernameAtom);
  return (
    <Input
      placeholder="Enter username"
      value={username}
      onChange={(e) => setUsername(e.target.value)}
    />
  );
}

function UsernamePreview() {
  const [username] = useAtom(usernameAtom);
  return (
    <Box mt={4}>
      <Text>Hello, {username || 'guest'}!</Text>
    </Box>
  );
}

function ProfileSection() {
  return (
    <Box p={4}>
      <UsernameInput />
      <UsernamePreview />
    </Box>
  );
}

Jotai shines when state is scattered across many independent pieces. Derived state can be computed using read-only atoms, and asynchronous atoms handle data fetching elegantly.

Pattern 7: React Query for Server State

Server state is fundamentally different from client state. It is asynchronous, can become stale, and often needs caching and synchronization. React Query (now part of TanStack Query) is the de facto standard for managing server state in modern React applications.

import { useQuery } from '@tanstack/react-query';
import {
  Box,
  Spinner,
  Text,
  Alert,
  AlertIcon,
  UnorderedList,
  ListItem,
} from '@chakra-ui/react';

async function fetchUsers() {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error('Failed to fetch users');
  return res.json();
}

function UserList() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isLoading) return <Spinner />;
  if (isError)
    return (
      <Alert status="error">
        <AlertIcon />
        {error.message}
      </Alert>
    );

  return (
    <Box>
      <Text mb={2} fontWeight="bold">Users:</Text>
      <UnorderedList>
        {data.map((user) => (
          <ListItem key={user.id}>{user.name}</ListItem>
        ))}
      </UnorderedList>
    </Box>
  );
}

By delegating server state to React Query, you keep your global state stores focused on client-only concerns such as UI preferences and authentication. This separation of concerns is one of the most important architectural decisions in modern state management.

Pattern 8: Form State with React Hook Form

Forms are a common source of state complexity. React Hook Form provides performant, uncontrolled form state management that integrates cleanly with Chakra UI inputs.

import { useForm } from 'react-hook-form';
import {
  FormControl,
  FormLabel,
  Input,
  Button,
  FormErrorMessage,
  VStack,
} from '@chakra-ui/react';

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm();

  const onSubmit = (data) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <VStack spacing={4}>
        <FormControl isInvalid={!!errors.email}>
          <FormLabel>Email</FormLabel>
          <Input
            type="email"
            {...register('email', { required: 'Email is required' })}
          />
          <FormErrorMessage>{errors.email?.message}</FormErrorMessage>
        </FormControl>

        <FormControl isInvalid={!!errors.password}>
          <FormLabel>Password</FormLabel>
          <Input
            type="password"
            {...register('password', {
              required: 'Password is required',
              minLength: { value: 6, message: 'Minimum 6 characters' },
            })}
          />
          <FormErrorMessage>{errors.password?.message}</FormErrorMessage>
        </FormControl>

        <Button type="submit" colorScheme="blue" isLoading={isSubmitting}>
          Submit
        </Button>
      </VStack>
    </form>
  );
}

React Hook Form minimizes re-renders by using uncontrolled inputs and refs. Combined with Chakra UI's FormControl, FormLabel, and FormErrorMessage components, you get accessible, validated forms with minimal boilerplate.

Best Practices for State Management in Chakra UI

1. Separate Client State from Server State

Do not store server-fetched data in your global client state store. Use React Query, SWR, or RTK Query for server state, and reserve Zustand, Redux, or Context for UI and application state. This separation prevents stale data bugs and simplifies caching.

2. Start Simple and Escalate Gradually

Begin with useState and useDisclosure. Lift state only when necessary. Reach for Context when props drilling becomes painful. Adopt Zustand or Jotai when Context causes re-render issues. Use Redux Toolkit only for genuinely complex state. Over-engineering state management early leads to unnecessary complexity.

3. Keep Stores Focused and Modular

Avoid creating a single monolithic store. Split stores by domain: one for authentication, one for cart, one for UI preferences. This modularity improves maintainability and reduces the risk of unintended coupling.

4. Use Selectors to Optimize Re-renders

Whether using Redux, Zustand, or Jotai, always subscribe to the smallest possible slice of state. This prevents components from re-rendering when unrelated state changes.

// Good: subscribes only to the count value
const count = useStore((state) => state.count);

// Bad: subscribes to the entire store
const state = useStore();
const count = state.count;

5. Leverage Chakra UI's Built-in Hooks

Chakra UI ships with several hooks that reduce the need for custom state management. useDisclosure handles open/close patterns, useColorMode manages theme state, and useToast manages notification state. Use these before building your own abstractions.

6. Persist Critical State When Needed

For state that should survive page reloads, such as theme preference or authentication tokens, combine your state library with persistence middleware. Zustand, for example, provides a persist middleware out of the box.

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useSettingsStore = create(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme }),
    }),
    { name: 'app-settings' }
  )
);

7. Test State Logic Independently

State logic should be decoupled from UI components so it can be tested in isolation. Extract reducers, store actions, and atoms into separate modules and write unit tests for them. This practice catches bugs early and makes refactoring safer.

Choosing the Right Tool

With so many options available, choosing the right state management approach can feel overwhelming. The following guidelines can help you decide:

These categories are not mutually exclusive. A typical Chakra UI application might use useDisclosure for modals, React Context for theme, Zustand for a shopping cart, and React Query for API data. The key is to match each piece of state with the tool best suited for its characteristics.

Conclusion

State management in Chakra UI applications is not about picking a single library and forcing every piece of state through it. Instead, it is about understanding the nature of each piece of state and selecting the appropriate tool for the job. Chakra UI's composable component model pairs naturally with React's built-in hooks for simple cases and scales gracefully to external libraries like Zustand, Redux Toolkit, Jotai, and React Query as complexity grows. By separating client state from server state, using selectors to optimize re-renders, leveraging Chakra UI's built-in hooks, and keeping stores modular, you can build applications that are both performant and maintainable. Start simple, escalate only when needed, and always let the actual requirements of your application guide your architectural decisions.

๐Ÿ›  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