Z
Frontend

Zustand: Lightweight State Management for React

Mayur Dabhi
Mayur Dabhi
June 17, 2026
14 min read

State management has long been one of the most debated topics in React development. For years, Redux was the default answer — powerful but notorious for its boilerplate, actions, reducers, and middleware chains. Then came the Context API, which solved smaller problems but caused performance headaches at scale. Zustand changes this conversation entirely: it's a tiny (~1KB), fast, and surprisingly flexible state management library that lets you manage global state with almost no ceremony.

Released by the team behind React Spring, Zustand (German for "state") has grown to over 45,000 GitHub stars and is the preferred choice for thousands of React projects. This guide covers everything from your first store to production patterns like middleware, persistence, and TypeScript integration.

Why Developers Love Zustand

Zustand's entire bundle is ~1KB gzipped — compare that to Redux Toolkit at ~40KB. It needs no providers wrapping your app, no boilerplate actions or reducers, and re-renders only the components that actually use the piece of state that changed.

What is Zustand?

Zustand is a minimal, unopinionated state management library built on top of React hooks. Its API surface is tiny: you call create() to define a store, and then call the resulting hook in any component that needs the state. That's essentially it.

What makes Zustand uniquely powerful is its design philosophy — it avoids the complexity traps that plague most state management solutions:

Zustand Store state + actions create(() => ({...})) Component A useStore(s => s.count) Component B useStore(s => s.user) Component C useStore(s => s.items) Any JS Code store.getState() Components subscribe to slices — only re-render on relevant changes

Zustand's subscription model: components subscribe to slices of state independently

Installation and Setup

Getting Zustand into your project takes a single command and no configuration whatsoever. It works with React 16.8+ (hooks-based) and has TypeScript types built in.

1

Install Zustand

Add Zustand to your project using npm, yarn, or pnpm. No peer dependencies to worry about.

Terminal
# npm
npm install zustand

# yarn
yarn add zustand

# pnpm
pnpm add zustand
2

Create Your First Store

Define a store file anywhere in your project — convention is src/store/ or src/stores/.

3

Use the Store in Components

Import the store hook and use it directly in any component — no Provider needed.

Creating Your First Store

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

src/store/counterStore.js
import { create } from 'zustand'

const useCounterStore = create((set) => ({
  // State
  count: 0,

  // Actions
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
  incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}))

export default useCounterStore

Notice how actions live right next to state — no separate action creators, no switch statements in reducers. The set function can accept either an object (partial state merge) or a function that receives the current state and returns the new state.

Using the Store in Components

src/components/Counter.jsx
import useCounterStore from '../store/counterStore'

function Counter() {
  // Subscribe to the entire store
  const { count, increment, decrement, reset } = useCounterStore()

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  )
}

// Another component that only reads the count
function CountDisplay() {
  // Use a selector to subscribe only to 'count'
  // This component only re-renders when count changes
  const count = useCounterStore((state) => state.count)
  return <p>Current count: {count}</p>
}
Performance Best Practice

Always use a selector when subscribing to a store: useStore(state => state.count). Without a selector, the component re-renders whenever any part of the store changes. With a selector, it only re-renders when that specific value changes.

Advanced State Patterns

Async Actions and Data Fetching

Zustand has no special handling needed for async operations. Because actions are just functions, you can make them async and call set at any point — before, during, or after an async operation.

src/store/userStore.js
import { create } from 'zustand'

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

  fetchUsers: async () => {
    set({ loading: true, error: null })
    try {
      const res = await fetch('https://jsonplaceholder.typicode.com/users')
      const users = await res.json()
      set({ users, loading: false })
    } catch (error) {
      set({ error: error.message, loading: false })
    }
  },

  selectUser: (id) => {
    const user = get().users.find((u) => u.id === id)
    set({ selectedUser: user })
  },

  clearSelected: () => set({ selectedUser: null }),
}))

export default useUserStore

Notice the second argument get — this gives you access to the current state from within any action, which is essential for operations that read before writing.

src/components/UserList.jsx
import { useEffect } from 'react'
import useUserStore from '../store/userStore'

function UserList() {
  const users = useUserStore((s) => s.users)
  const loading = useUserStore((s) => s.loading)
  const error = useUserStore((s) => s.error)
  const fetchUsers = useUserStore((s) => s.fetchUsers)
  const selectUser = useUserStore((s) => s.selectUser)

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

  if (loading) return <div>Loading users...</div>
  if (error) return <div>Error: {error}</div>

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id} onClick={() => selectUser(user.id)}>
          {user.name} — {user.email}
        </li>
      ))}
    </ul>
  )
}

Derived State with Selectors

Selectors can compute derived values on the fly. For expensive computations, combine Zustand with useMemo or use the dedicated useShallow hook to compare objects by value rather than reference.

Computed/Derived State
import { useShallow } from 'zustand/react/shallow'
import useCartStore from '../store/cartStore'

function CartSummary() {
  // useShallow prevents re-renders when object identity changes
  // but values are the same
  const { itemCount, totalPrice } = useCartStore(
    useShallow((state) => ({
      itemCount: state.items.reduce((sum, item) => sum + item.qty, 0),
      totalPrice: state.items.reduce(
        (sum, item) => sum + item.price * item.qty,
        0
      ),
    }))
  )

  return (
    <div>
      <p>{itemCount} items — ${totalPrice.toFixed(2)}</p>
    </div>
  )
}

Middleware: Persistence and DevTools

Zustand ships with a composable middleware system. The two most commonly used are persist (stores state in localStorage/sessionStorage) and devtools (integrates with Redux DevTools browser extension).

Persist Middleware

src/store/settingsStore.js — with persistence
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

const useSettingsStore = create(
  persist(
    (set) => ({
      theme: 'dark',
      language: 'en',
      notifications: true,
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
      toggleNotifications: () =>
        set((state) => ({ notifications: !state.notifications })),
    }),
    {
      name: 'app-settings',           // localStorage key
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({       // only persist these fields
        theme: state.theme,
        language: state.language,
      }),
    }
  )
)

export default useSettingsStore
Persist Gotcha

The partialize option is important — without it, actions (functions) will also be serialized, causing errors. Always use partialize to specify only serializable state (not functions) for persistence.

DevTools Middleware

src/store/todoStore.js — with DevTools
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

const useTodoStore = create(
  devtools(
    (set) => ({
      todos: [],
      filter: 'all',

      addTodo: (text) =>
        set(
          (state) => ({ todos: [...state.todos, { id: Date.now(), text, done: false }] }),
          false,           // false = merge (not replace)
          'todos/addTodo'  // action name shown in DevTools
        ),

      toggleTodo: (id) =>
        set(
          (state) => ({
            todos: state.todos.map((t) =>
              t.id === id ? { ...t, done: !t.done } : t
            ),
          }),
          false,
          'todos/toggleTodo'
        ),

      removeTodo: (id) =>
        set(
          (state) => ({ todos: state.todos.filter((t) => t.id !== id) }),
          false,
          'todos/removeTodo'
        ),

      setFilter: (filter) => set({ filter }, false, 'todos/setFilter'),
    }),
    { name: 'TodoStore' }
  )
)

export default useTodoStore

Combining Middleware

You can combine persist, devtools, and immer together. The recommended order is devtools(persist(immer(...))):

Combined Middleware Pattern
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

const useStore = create(
  devtools(
    persist(
      immer((set) => ({
        items: [],
        addItem: (item) =>
          set((state) => {
            // With immer, mutate directly!
            state.items.push(item)
          }),
        removeItem: (id) =>
          set((state) => {
            state.items = state.items.filter((i) => i.id !== id)
          }),
      })),
      { name: 'my-store' }
    ),
    { name: 'MyStore' }
  )
)

TypeScript Integration

Zustand has excellent TypeScript support. The recommended pattern is to define your store's type interface and pass it as a generic to create:

src/store/cartStore.ts — TypeScript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface CartItem {
  id: number
  name: string
  price: number
  qty: number
}

interface CartState {
  items: CartItem[]
  addItem: (item: Omit<CartItem, 'qty'>) => void
  removeItem: (id: number) => void
  updateQty: (id: number, qty: number) => void
  clearCart: () => void
  totalPrice: () => number
}

const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      items: [],

      addItem: (item) =>
        set((state) => {
          const existing = state.items.find((i) => i.id === item.id)
          if (existing) {
            return {
              items: state.items.map((i) =>
                i.id === item.id ? { ...i, qty: i.qty + 1 } : i
              ),
            }
          }
          return { items: [...state.items, { ...item, qty: 1 }] }
        }),

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

      updateQty: (id, qty) =>
        set((state) => ({
          items: qty === 0
            ? state.items.filter((i) => i.id !== id)
            : state.items.map((i) => (i.id === id ? { ...i, qty } : i)),
        })),

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

      totalPrice: () =>
        get().items.reduce((sum, item) => sum + item.price * item.qty, 0),
    }),
    {
      name: 'shopping-cart',
      partialize: (state) => ({ items: state.items }),
    }
  )
)

export default useCartStore
export type { CartItem, CartState }
TypeScript Tip

Note the extra ()() when using middleware with TypeScript: create<State>()(persist(...)). This is required due to TypeScript's inability to infer generic types with curried functions in a single call.

Real-World Example: Shopping Cart

Let's build a complete shopping cart using the TypeScript store from above. Here's how components consume the store cleanly:

function ProductCard({ product }) {
  const addItem = useCartStore((s) => s.addItem)

  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price.toFixed(2)}</p>
      <button onClick={() => addItem(product)}>
        Add to Cart
      </button>
    </div>
  )
}
function CartSidebar() {
  const items = useCartStore((s) => s.items)
  const removeItem = useCartStore((s) => s.removeItem)
  const updateQty = useCartStore((s) => s.updateQty)
  const totalPrice = useCartStore((s) => s.totalPrice)
  const clearCart = useCartStore((s) => s.clearCart)

  return (
    <aside>
      <h2>Your Cart ({items.length} items)</h2>
      {items.map((item) => (
        <div key={item.id}>
          <span>{item.name}</span>
          <input
            type="number"
            value={item.qty}
            onChange={(e) => updateQty(item.id, +e.target.value)}
            min={0}
          />
          <span>${(item.price * item.qty).toFixed(2)}</span>
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
      <hr />
      <strong>Total: ${totalPrice().toFixed(2)}</strong>
      <button onClick={clearCart}>Clear Cart</button>
    </aside>
  )
}
// This component ONLY re-renders when item count changes
// Even if prices update, it stays quiet
function CartBadge() {
  const itemCount = useCartStore(
    (s) => s.items.reduce((sum, item) => sum + item.qty, 0)
  )

  if (itemCount === 0) return null

  return (
    <span className="badge">
      {itemCount}
    </span>
  )
}

Zustand vs. Other State Libraries

Choosing a state management library is highly context-dependent. Here's an honest comparison to help you decide:

Feature Zustand Redux Toolkit Context API Jotai
Bundle size ~1KB ~40KB Built-in ~3KB
Boilerplate Minimal Moderate Low Minimal
Provider needed No Yes Yes Optional
DevTools Via middleware Built-in No Via Jotai DevTools
Async support Native (just async fn) createAsyncThunk Manual Async atoms
Re-render optimization Selectors useSelector Poor (whole tree) Atom-level
Works outside React Yes Yes No No
Learning curve Very low Moderate Low Low
Best for Most apps Complex enterprise apps Simple/low-frequency state Atomic, fine-grained state

The bottom line: use Zustand for almost any React project that needs global state. Reach for Redux Toolkit only if you need a highly structured team convention or have extremely complex state interactions with many developers. Avoid Context API for state that changes frequently — it doesn't optimize re-renders.

Zustand Patterns Quick Reference

PatternCode
Read entire store const state = useStore()
Read one field const count = useStore(s => s.count)
Read multiple fields const {a, b} = useStore(useShallow(s => ({a: s.a, b: s.b})))
Update state (merge) set({ count: 5 })
Update state (functional) set(s => ({ count: s.count + 1 }))
Replace state (not merge) set({ count: 0 }, true)
Read state in action const value = get().someField
Access store outside React useStore.getState().someAction()
Subscribe outside React useStore.subscribe(listener)
Reset store set(initialState, true)

When to Use Zustand

Zustand is an excellent default choice for the vast majority of React applications. Here's a practical decision guide:

Use Zustand When You Need

  • Global state shared across unrelated components — user session, theme, cart, notifications
  • State that lives outside the component tree — accessed in utility functions, WebSocket handlers, or service workers
  • Async state with loading/error management — API calls, form submissions, file uploads
  • Persisted state — settings, draft content, shopping cart (persist middleware handles localStorage)
  • Performance-sensitive state — large lists, frequent updates where you need surgical re-renders
  • Teams wanting fast onboarding — new developers understand Zustand in under an hour
"Zustand finally made me stop thinking about state management and start thinking about my product. It's the solution that gets out of your way."
— Common sentiment across the React community

Zustand represents a maturation of React state management thinking. It doesn't ask you to learn a new paradigm — it simply wraps JavaScript objects in a reactive shell. The library's philosophy trusts developers to structure their state sensibly rather than imposing rigid patterns, which is why it scales from tiny side projects to production applications at companies like Shopify, Vercel, and many others.

Start with a single store, see how far it takes you, and split into multiple stores only when complexity demands it. You'll be surprised how often a well-designed Zustand store handles what once required hundreds of lines of Redux boilerplate.

Zustand React State Management JavaScript Frontend TypeScript
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.