# Zustand: simple React state management

> Learn Zustand through a React store: actions, selectors, immutable updates, persistence, testing, SSR boundaries, and when local or server state is better.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-07 | Topics: [React](https://flaviocopes.com/tags/react/) | Canonical: https://flaviocopes.com/zustand/

**Zustand** is a small state-management library for React.

You create a store, put state and actions inside it, then select the pieces each component needs. No provider is required for a normal client-side store.

Use it for **client-owned shared state**: a shopping cart, a multi-step wizard, playback controls, editor state, or preferences used across distant components.

Do not put every value in a global store.

```text
one component owns it       -> useState
several components share it -> Zustand can help
the server owns it          -> TanStack Query or a framework loader
```

If React state and rendering are new to you, start with the free [React course](https://flaviocopes.com/courses/react/).

## Install Zustand

Install the package:

```bash
npm install zustand
```

We will build a small cart. The store will hold items and expose actions that change them.

## Create the first store

Create `cart-store.js`:

```js
import { create } from 'zustand'

export const useCartStore = create(set => ({
  items: [],

  addItem: product => {
    set(state => ({
      items: [...state.items, product]
    }))
  },

  clearCart: () => {
    set({ items: [] })
  }
}))
```

`create()` returns a React hook with a store API attached.

The callback receives `set`. Calling `set()` updates the store and notifies subscribed components.

When the new value depends on current state, pass an updater function:

```js
set(state => ({
  items: [...state.items, product]
}))
```

For an independent value, pass an object:

```js
set({ items: [] })
```

By default, Zustand shallowly merges the returned object into the current store.

## Select only what the component needs

Read the item count with a selector:

```jsx
import { useCartStore } from './cart-store.js'

export function CartCount() {
  const count = useCartStore(state => state.items.length)

  return <span>{count} items</span>
}
```

The component subscribes to the selector result. It re-renders when that result changes.

Select the action in another component:

```jsx
import { useCartStore } from './cart-store.js'

export function AddToCartButton({ product }) {
  const addItem = useCartStore(state => state.addItem)

  return (
    <button onClick={() => addItem(product)}>
      Add to cart
    </button>
  )
}
```

The action function normally keeps the same reference, so cart updates do not cause this component to re-render through the store subscription.

Avoid this unless the component needs the complete store:

```jsx
const store = useCartStore()
```

That component subscribes to every store change.

## Build actions around the state

Components should express intent. They should not know how every store update works.

Add quantity-aware actions:

```js
export const useCartStore = create(set => ({
  items: [],

  addItem: product => {
    set(state => {
      const existing = state.items.find(
        item => item.id === product.id
      )

      if (existing) {
        return {
          items: state.items.map(item =>
            item.id === product.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          )
        }
      }

      return {
        items: [
          ...state.items,
          { ...product, quantity: 1 }
        ]
      }
    })
  },

  removeItem: productId => {
    set(state => ({
      items: state.items.filter(item => item.id !== productId)
    }))
  },

  clearCart: () => {
    set({ items: [] })
  }
}))
```

Now the UI calls `addItem(product)` and `removeItem(productId)`. The cart rules stay in one place.

## Update state immutably

Treat objects and arrays in the store as immutable.

This is wrong:

```js
set(state => {
  state.items.push(product)
  return state
})
```

The same object references can prevent subscriptions from seeing the change. Mutation also makes state history harder to reason about.

Create new arrays and objects instead:

```js
set(state => ({
  items: [...state.items, product]
}))
```

For deeply nested state, you must copy each changed level. If that becomes painful, flatten the store or use Zustand's Immer middleware.

Do not add Immer for one small nested object. A simpler state shape is often the better fix.

## Derived values belong in selectors

Do not store a total that can be calculated from items. Two stored values can drift apart.

Create a selector:

```js
const selectTotal = state =>
  state.items.reduce(
    (total, item) => total + item.price * item.quantity,
    0
  )
```

Use it in a component:

```jsx
function CartTotal() {
  const total = useCartStore(selectTotal)

  return <strong>€{(total / 100).toFixed(2)}</strong>
}
```

The store keeps integer minor units such as cents. The component formats them for display.

## Selecting more than one value

This selector creates a new object on every call:

```jsx
const cart = useCartStore(state => ({
  count: state.items.length,
  clearCart: state.clearCart
}))
```

Even when the values inside stay the same, the object reference is new.

Use `useShallow` when you want a small object or array of top-level values:

```jsx
import { useShallow } from 'zustand/react/shallow'

const cart = useCartStore(useShallow(state => ({
  count: state.items.length,
  clearCart: state.clearCart
})))
```

`useShallow` compares the top-level values. It does not deeply compare nested objects.

My advice is to start with separate selectors. Reach for `useShallow` when grouping values makes the component clearer.

## Read and update outside React

The hook also exposes store methods:

```js
const cart = useCartStore.getState()
console.log(cart.items)

useCartStore.getState().clearCart()
```

You can subscribe without rendering a component:

```js
const unsubscribe = useCartStore.subscribe(state => {
  console.log(state.items.length)
})

unsubscribe()
```

This is useful for browser integrations and tests.

Be careful on the server. A module-level store can be shared between requests. User A's state must never appear in user B's render.

## Persist selected state

Zustand's `persist` middleware can save store state in `localStorage`.

Persist only the cart items:

```js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useCartStore = create(
  persist(
    set => ({
      items: [],

      addItem: product => {
        set(state => ({
          items: [...state.items, product]
        }))
      },

      clearCart: () => {
        set({ items: [] })
      }
    }),
    {
      name: 'cart',
      partialize: state => ({
        items: state.items
      })
    }
  )
)
```

`name` is the storage key. `partialize` chooses what is saved.

Do not store secrets, access tokens, or trusted authorization state in `localStorage`. Users and scripts on the page can read and change it.

A persisted cart is a convenience. The server must still look up current prices and availability during checkout.

## Version persisted data

Stored state can outlive your code deployment.

If the cart shape changes, add a version and migration:

```js
{
  name: 'cart',
  version: 1,
  migrate: (state, version) => {
    if (version === 0) {
      return {
        ...state,
        items: state.products ?? []
      }
    }

    return state
  }
}
```

Without a migration, old browser data can break a new component days after deployment.

Keep persisted shapes small. Every stored field becomes data you may need to migrate.

## Reset the store

Logout and test cleanup often need a complete reset.

Keep the initial state in one value:

```js
const initialState = {
  items: [],
  couponCode: null
}

export const useCartStore = create(set => ({
  ...initialState,

  reset: () => {
    set(initialState)
  }
}))
```

If the store is persisted, decide whether reset should also clear storage. Logging out should not leave another user's private client state behind.

## Test the store without rendering React

Actions are plain functions on the store API.

Reset before each test:

```js
import { beforeEach, expect, test } from 'vitest'
import { useCartStore } from './cart-store.js'

beforeEach(() => {
  useCartStore.setState({ items: [] })
})

test('adds a product', () => {
  useCartStore.getState().addItem({
    id: 'book',
    price: 1900
  })

  expect(useCartStore.getState().items).toHaveLength(1)
})
```

Focused store tests are fast. Keep component tests for rendering and user interaction.

## Zustand and server rendering

A browser-only single-page application can use one module-level store.

Server rendering changes the rules. A server handles many users in one process, so a global mutable store can leak state between requests.

For SSR frameworks:

- create a store per request
- initialize the browser store with the same data used on the server
- avoid reading or writing the store from React Server Components
- use a provider when you need a per-request store instance

Follow the current Zustand guide for your framework. Hydration bugs can look like random interface changes, while shared server state is a security problem.

## Zustand versus Context

[React Context](https://flaviocopes.com/react-context-api/) passes values through a component tree. It is excellent for stable dependencies such as a theme, locale, or service object.

When a context value changes, every consumer of that context can re-render. You can split contexts and memoize values, but frequent shared state needs care.

Zustand gives each component a selector subscription. It also works outside React.

Use Context when the component tree is the natural scope. Use Zustand when an independent store and fine-grained subscriptions make the model clearer.

## Zustand versus Redux

[Redux](https://flaviocopes.com/redux/) has stricter conventions, a large ecosystem, excellent devtools, and predictable event-style updates. Those qualities help large teams and complex workflows.

Zustand has less ceremony. You can build a useful store in one file without reducers or a provider.

Do not choose only by line count. Choose the state model your team can debug six months later.

## Zustand versus TanStack Query

Zustand manages client state. TanStack Query manages server state.

Do not copy fetched posts into Zustand just to make them global. You would have to rebuild caching, freshness, retries, and invalidation.

Use [TanStack Query](https://flaviocopes.com/tanstack-query/) for API data. Use Zustand for what the user is doing in the interface.

They can live in the same application:

```text
TanStack Query -> products and account data
Zustand        -> cart drawer, draft selections, editor mode
```

## Common mistakes

### One giant store

Unrelated state creates accidental coupling. Split stores by feature or lifecycle when they stop changing together.

### Selecting the whole store

The component re-renders for unrelated changes. Select the smallest useful value.

### Mutating arrays and objects

Create new references for changed state.

### Persisting everything

Storage is not free state management. Persist only values that should survive a reload.

### Trusting persisted values

Anything in the browser can be changed. Revalidate prices, permissions, and identifiers on the server.

### Using a global store during SSR

Create stores per request. Never let request-specific data live in shared module state.

### Moving local state too early

An input used by one form does not become better because it lives globally. Start with `useState`.

## How I would use Zustand

I would start with local React state.

I would move a value to Zustand when distant components need it, prop passing becomes noise, or non-React code needs access to the same state.

I would keep actions close to the data rules and use small selectors in components. I would persist only a few convenience values, with a version from the first release.

I would not use Zustand as a second database cache or as proof that a user can perform an action. The server still owns durable data and authorization.

Zustand is useful because the model stays small: state, actions, selectors. Keep it that way.
