Skip to content
React rc state-management 5 min read

Zustand

Zustand (German for “state”) is a tiny, hook-based state manager that gives you global state without providers, reducers, action types, or boilerplate. You define a store as a single hook, read exactly the slice you need with a selector, and mutate it by calling plain functions. It is unopinionated, fast, and around 1 KB gzipped — which is why it has become the go-to lightweight alternative to Redux for many React apps.

Three things set Zustand apart. First, there is no <Provider> — the store lives in module scope, so any component (or even non-React code) can import and use it. Second, selectors keep re-renders surgical: a component only re-renders when the specific value it subscribes to changes. Third, the API is just functions — actions are ordinary methods you put inside the store, so there is nothing new to learn beyond create and set.

Installation

Add the single package — it has no peer dependencies beyond React:

npm install zustand

Creating a store

create takes a function that receives set (and optionally get) and returns your initial state plus the actions that update it. The result is a custom hook you call from any component.

// stores/useCounterStore.js
import { create } from 'zustand';

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

set merges the object you return into the existing state (a shallow merge, like this.setState in old class components). Pass it a function when the new value depends on the previous state, or a plain object when it does not.

Subscribing with selectors

Call the hook with a selector that picks out just the piece of state a component needs. Zustand compares the selected value between renders and only re-renders when it actually changes.

// Counter.jsx
import { useCounterStore } from './stores/useCounterStore';

export default function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const incrementBy = useCounterStore((state) => state.incrementBy);

  return (
    <section>
      <h2>Count: {count}</h2>
      <button onClick={increment}>+1</button>
      <button onClick={() => incrementBy(5)}>+5</button>
    </section>
  );
}

A component that selects only state.increment never re-renders when count changes, because the function identity is stable. This is what makes Zustand feel fast by default.

Calling the hook with no selector — useCounterStore() — returns the entire store object, so the component re-renders on every state change. Always select the narrowest slice you need.

Selecting multiple values

When you need several values at once, return them with useShallow so Zustand compares the fields shallowly instead of by object identity (a fresh object literal would otherwise re-render on every store update).

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

function Toolbar() {
  const { count, reset } = useCounterStore(
    useShallow((state) => ({ count: state.count, reset: state.reset }))
  );

  return <button onClick={reset}>Reset ({count})</button>;
}

Actions and async logic

Because actions are just methods on the store, async work is trivial — await inside the action and call set when the data arrives. Use get to read current state without subscribing.

// stores/useTodosStore.js
import { create } from 'zustand';

export const useTodosStore = create((set, get) => ({
  items: [],
  loading: false,
  fetchTodos: async () => {
    set({ loading: true });
    const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=3');
    const items = await res.json();
    set({ items, loading: false });
  },
  toggle: (id) =>
    set((state) => ({
      items: state.items.map((t) =>
        t.id === id ? { ...t, completed: !t.completed } : t
      ),
    })),
  remaining: () => get().items.filter((t) => !t.completed).length,
}));
// Todos.jsx
import { useEffect } from 'react';
import { useTodosStore } from './stores/useTodosStore';

export default function Todos() {
  const items = useTodosStore((state) => state.items);
  const loading = useTodosStore((state) => state.loading);
  const fetchTodos = useTodosStore((state) => state.fetchTodos);
  const toggle = useTodosStore((state) => state.toggle);

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

  if (loading) return <p>Loading…</p>;

  return (
    <ul>
      {items.map((t) => (
        <li key={t.id} onClick={() => toggle(t.id)}
            style={{ textDecoration: t.completed ? 'line-through' : 'none' }}>
          {t.title}
        </li>
      ))}
    </ul>
  );
}

After the fetch resolves and the first todo is toggled, the store holds:

Output:

items: [
  { id: 1, title: "delectus aut autem",      completed: true  },
  { id: 2, title: "quis ut nam facilis...",  completed: false },
  { id: 3, title: "fugiat veniam minus",     completed: false }
]
loading: false

Middleware

Zustand ships small middleware that wrap your store creator. The two most common are persist (save to localStorage) and devtools (Redux DevTools integration). They compose by nesting.

// stores/useSettingsStore.js
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';

export const useSettingsStore = create(
  devtools(
    persist(
      (set) => ({
        theme: 'light',
        toggleTheme: () =>
          set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
      }),
      { name: 'settings' } // localStorage key
    )
  )
);
MiddlewareImportPurpose
persistzustand/middlewareSaves and rehydrates state from localStorage (or any storage).
devtoolszustand/middlewareStreams actions to the Redux DevTools extension for time-travel.
immerzustand/middleware/immerLets you write “mutating” updates safely (see Immer).
subscribeWithSelectorzustand/middlewareAdds fine-grained subscribe(selector, callback) outside React.

Best Practices

  • Always read state through a narrow selector; selecting the whole store defeats Zustand’s render optimization.
  • Use useShallow when a selector returns a new object or array of multiple fields.
  • Keep actions inside the store so all mutation logic is co-located and testable in isolation.
  • Use the functional form of set whenever the next value depends on the previous state.
  • Split unrelated concerns into separate stores rather than one giant global object — there is no Provider cost to having many.
  • Reach for persist for user preferences and devtools during development; order middleware as devtools(persist(...)).
  • Access the store outside React via useStore.getState() / useStore.setState() for event handlers, tests, or non-component code.
Last updated June 14, 2026
Was this helpful?