Lightweight State

Zustand

Zustand is a tiny, fast and unopinionated state store for React. No providers, no boilerplate, and selector-based subscriptions that keep re-renders minimal.

intermediate13 min readUpdated Sep 15, 2026
store.js
js
// store.js
import { create } from "zustand";

export const useStore = create((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}));
Size
About 1kB
Provider
Not required
Create with
create
Read with
Selectors
Async
Plain async functions
Middleware
persist, devtools, immer

Why it matters

Why Zustand feels refreshing

Tiny and fast

Around a kilobyte with selector-based subscriptions, so components re-render only when the exact value they use changes.

Almost no boilerplate

A store is a single function call. There are no providers, reducers, action types or context wrappers to wire up.

Unopinionated

Keep state and actions together, split into slices, or use middleware. Zustand stays out of your way.

The big picture

The three ideas behind Zustand

One hook, a plain object of state and actions, and selector-based subscriptions. That is the entire model.

The store

State

A hook created with create that holds state and the actions that update it.

Selectors

Subscriptions

Components read the exact slice they need and re-render only when it changes.

Actions

Updates

Plain functions that call set to merge new state, with no reducers or dispatch required.

Zustand at a glance

The core of Zustand

create

Build a store hook with state and actions in one place.

Selectors

Subscribe to a slice of state so unrelated changes do not re-render.

set and get

Update state with set and read it imperatively with get.

Async actions

Write async functions directly in the store, no middleware required.

persist middleware

Save and rehydrate state in localStorage or another storage.

Middleware

Add devtools, immutability helpers and persistence by composing functions.

A short history

Small library, big adoption

  1. 2019

    Zustand released

    A small store from the developers of Jotai and the React Spring ecosystem.

    19
  2. 2020

    Growing adoption

    Developers tired of boilerplate adopt it for its simplicity and selector model.

    20
  3. 2022

    Zustand 4

    Better TypeScript support and a smaller, more flexible core.

    22
  4. 2024

    Zustand 5

    A cleaner API and improved compatibility with React 18 and 19.

    24
  5. Today

    A modern default

    A common choice for global client state in React projects of every size.

    Today

The complete guide

Zustand: Everything you need to know

What is Zustand?

Zustand is a small, fast state store for React. Its whole API fits in your head: you call create with a function that returns state and actions, and you get back a hook. There is no provider, no reducer, no action types and no dispatch. You import the hook and use it.

The name is German for “state”, and the library lives up to the simplicity. It is around a kilobyte, uses selector-based subscriptions to keep re-renders minimal, and works with plain JavaScript and TypeScript. For many React apps it is the least ceremony you can add and still share state globally.

Creating a store

A store is a hook. State and the functions that update it live together.

// store.js
import { create } from "zustand";

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

  double: () => get().count * 2,
}));

set merges the returned object into the state, and get reads the current state imperatively. There is no reducer to write and no action to dispatch — just functions that update state.

Reading state with selectors

Components subscribe to the exact slice they need.

// Counter.jsx
import { useStore } from "./store";

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

  return <button onClick={increment}>{count}</button>;
}

Because the component subscribes to state.count only, updates to unrelated parts of the store do not re-render it. That is the key performance difference from React’s Context API, which re-renders every consumer when the value changes.

For selecting multiple values, use a shallow comparison so the component re-renders only when one of them actually changes:

// shallow.js
import { useShallow } from "zustand/react/shallow";

const { count, increment } = useStore(
  useShallow((state) => ({ count: state.count, increment: state.increment })),
);

Updating state

set merges by default, so you only specify what changed. Pass a function when the next value depends on the previous one.

// updates.js
set({ count: 0 }); // merge
set((state) => ({ count: state.count + 1 })); // derive from previous
set((state) => ({ items: [...state.items, item] })); // arrays

For nested objects, set replaces the top-level key, so spread the existing object when you need to keep other properties. The Immer middleware is available if you prefer mutating syntax for deep updates.

Async actions

Async logic is just an async function in the store. There is no middleware to configure.

// usersStore.js
import { create } from "zustand";

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

  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const res = await fetch("/api/users");
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      set({ users: await res.json(), loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
}));

The loading and error flags live right next to the data, and components select only the pieces they display.

Slices and middleware

For a large store, split it into slices that are combined into one store. Each slice is a function that receives set and get.

// slices/cart.js
export const createCartSlice = (set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  clear: () => set({ items: [] }),
});
// store.js
import { create } from "zustand";
import { createCartSlice } from "./slices/cart";
import { createUserSlice } from "./slices/user";

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

Middleware composes like functions around the store creator. The most common are:

  • persist saves and rehydrates state in localStorage or another storage.
  • devtools connects the store to the Redux DevTools extension.
  • immer lets you write mutating updates that stay immutable.
// persisted.js
import { persist } from "zustand/middleware";

export const useSettings = create(
  persist(
    (set) => ({
      theme: "dark",
      setTheme: (theme) => set({ theme }),
    }),
    { name: "settings" },
  ),
);

When to use Zustand

Zustand is a great fit for global client state: theme and preferences, a shopping cart, UI state shared across distant components, drafts, filters and modal state. It is especially nice when Context would cause too many re-renders or when Redux feels like more structure than you need.

Do not use it for server state. Data that comes from an API needs caching, deduplication, background refetching and invalidation, and TanStack Query handles those concerns far better. The common setup is Zustand for client state and TanStack Query for server state.

Best practices

  • Always read state with a selector.
  • Use useShallow when selecting multiple values.
  • Keep actions in the store so components do not know the state shape.
  • Split large stores into slices.
  • Use the persist middleware for settings that should survive reloads.
  • Keep server data out of the store.
  • Store plain, serialisable data where possible.

Common mistakes

  • Calling useStore() with no selector and re-rendering on every change.
  • Returning a new object from a selector without a shallow comparison.
  • Treating Zustand as a server-state cache.
  • Mutating nested state without Immer and losing updates.
  • Creating a store inside a component, which resets it on every render.
  • Adding Redux-style ceremony that Zustand was designed to remove.

Where to go next

Zustand is often the sweet spot between Context and Redux. Compare it with Context for simple sharing and Redux Toolkit for strict, tooled-up state, and move server data to TanStack Query. For Vue, the same role is played by Pinia.

Subscribing to state

Select only what the component needs. Selecting the whole store re-renders on every change.

Prefer
const count = useStore((s) => s.count);
const increment = useStore((s) => s.increment);
Avoid
const { count, other, more } = useStore();
// re-renders on any change

Defining updates

Keep actions in the store so components do not need to know how state is shaped.

Prefer
const useStore = create((set) => ({
  count: 0,
  increment: () =>
    set((s) => ({ count: s.count + 1 })),
}));
Avoid
const useStore = create(() => ({
  count: 0,
}));

// component does
// useStore.setState({ count: 1 })

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Zustand?

Our interactive tutorial walks you through Zustand step by step — with quizzes and real code you can run in the browser.