Skip to content
Codeloom
React

Zustand for Lightweight State Management in React

Learn Zustand, the minimal React state management library with no boilerplate, built-in selectors, and a hook-based API.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • Creating a Zustand store
  • Selectors and preventing unnecessary re-renders
  • Async actions and middleware
  • Persisting state to localStorage
  • Comparing Zustand to Redux and Context

Prerequisites

  • Basic React knowledge
  • Familiarity with hooks

Zustand is a small, fast state management library for React. It gives you a global store with a hook-based API, built-in selectors for performance, and zero boilerplate. There are no providers, no reducers, no action types. You create a store, use it in your components, and that is it.

Creating a Store

A Zustand store is created with the create function. You pass it a callback that receives a set function and returns an object with your state and actions.

import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

The create function returns a hook. Use it in any component:

function Counter() {
  const count = useStore((state) => state.count);
  const increment = useStore((state) => state.increment);
  const decrement = useStore((state) => state.decrement);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

No <Provider> wrapping. No context. The store lives outside the React tree, and components subscribe to the slices they need.

Selectors: The Key to Performance

The function you pass to useStore is a selector. Zustand only re-renders the component when the selected value changes (using Object.is comparison). This is the critical performance advantage over Context.

// Only re-renders when `count` changes
function CountDisplay() {
  const count = useStore((state) => state.count);
  return <p>{count}</p>;
}

// Only re-renders when `user` changes
function UserGreeting() {
  const user = useStore((state) => state.user);
  return <p>Hello, {user?.name}</p>;
}

If you select multiple values, return them in a shallow-compared object using useShallow:

import { useShallow } from 'zustand/react/shallow';

function UserInfo() {
  const { name, email } = useStore(
    useShallow((state) => ({ name: state.user.name, email: state.user.email }))
  );

  return (
    <div>
      <p>{name}</p>
      <p>{email}</p>
    </div>
  );
}

Without useShallow, the component would re-render on every store update because the selector creates a new object each time.

Async Actions

Actions can be async. Use set after the async operation completes.

const useStore = create((set) => ({
  users: [],
  loading: false,
  error: null,

  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const res = await fetch('/api/users');
      const users = await res.json();
      set({ users, loading: false });
    } catch (err) {
      set({ error: err.message, loading: false });
    }
  },
}));
function UserList() {
  const users = useStore((state) => state.users);
  const loading = useStore((state) => state.loading);
  const fetchUsers = useStore((state) => state.fetchUsers);

  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  if (loading) return <p>Loading...</p>;
  return (
    <ul>
      {users.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

Accessing State Outside React

Zustand stores expose getState and setState methods for use outside React components. This is useful in utility functions, API interceptors, or tests.

// Read state
const currentCount = useStore.getState().count;

// Update state
useStore.setState({ count: 0 });

// Subscribe to changes
const unsub = useStore.subscribe(
  (state) => console.log('Count is now:', state.count)
);

Middleware: Persist

Zustand has a plugin system based on middleware. The persist middleware saves state to localStorage and restores it on reload.

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

const useSettingsStore = create(
  persist(
    (set) => ({
      theme: 'light',
      language: 'en',
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
    }),
    {
      name: 'settings-storage', // localStorage key
      partialize: (state) => ({
        theme: state.theme,
        language: state.language,
      }),
    }
  )
);

The partialize option controls which parts of the state are persisted. Actions and computed values are excluded automatically.

Middleware: Devtools

Connect your store to the Redux DevTools browser extension for time-travel debugging.

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

const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set(
        (state) => ({ count: state.count + 1 }),
        false,
        'increment' // action name in devtools
      ),
    }),
    { name: 'CounterStore' }
  )
);

Combining Middleware

Middleware composes by nesting. Combine devtools and persist:

const useStore = create(
  devtools(
    persist(
      (set) => ({
        todos: [],
        addTodo: (text) =>
          set(
            (state) => ({ todos: [...state.todos, { id: Date.now(), text, done: false }] }),
            false,
            'addTodo'
          ),
      }),
      { name: 'todo-storage' }
    ),
    { name: 'TodoStore' }
  )
);

Slicing Stores

For large applications, split your store into slices and combine them.

const createUserSlice = (set) => ({
  user: null,
  login: (user) => set({ user }),
  logout: () => set({ user: null }),
});

const createCartSlice = (set) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  clearCart: () => set({ items: [] }),
});

const useStore = create((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}));

Each slice is a plain function that receives set and returns a piece of the state. The combined store merges them.

Zustand vs Redux vs Context

FeatureZustandRedux ToolkitContext
BoilerplateMinimalModerateLow
Bundle size~1 KB~11 KB0 (built-in)
Provider requiredNoYesYes
Built-in selectorsYesVia useSelectorNo
DevtoolsMiddlewareBuilt-inNo
Async actionsNativeVia thunks/sagasManual
Learning curveLowMediumLow

Use Zustand when you need global state without the ceremony of Redux. Use Context for truly static, rarely-changing values like themes. Use Redux Toolkit when you need the ecosystem: RTK Query, structured patterns for large teams, and extensive middleware.

Zustand hits the sweet spot for most React applications. It is small enough to learn in an afternoon and powerful enough to manage complex state at scale.