Zustand — Modern State Management
Zustand store creation, selectors, middleware, slices pattern, and persistence..
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Node.js 18+, React 18+, npm or yarn, basic understanding of React hooks (useState, useEffect), familiarity with JavaScript ES6+ (arrow functions, destructuring, async/await).
Zustand — Modern State Management: A core React concept for building modern user interfaces. It helps you structure your components efficiently and handle data flow predictably.
React is a JavaScript library for building user interfaces. This article covers zustand — a key concept for building modern web applications.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
A comprehensive guide to zustand — modern state management with production examples and best practices.
Why Zustand? The Problem with Boilerplate State Management
State management in React has long been dominated by Redux, which, while powerful, introduces significant boilerplate: action types, reducers, dispatch, connect, and middleware. For many applications, this overhead is unnecessary. Zustand offers a minimal API that eliminates boilerplate while preserving the benefits of a centralized store. It's a single hook-based store that requires no providers, no context, and no reducers. This section explains the core motivation: when you need global state but don't want to write 50 lines of code to increment a counter. Zustand is not a replacement for Redux in every scenario, but for most CRUD apps, dashboards, and even complex state interactions, it's the right tool. We'll also touch on its performance characteristics: Zustand uses subscriptions to avoid unnecessary re-renders, and it's framework-agnostic, meaning you can use it with vanilla JS or other frameworks.
Creating Your First Store: The Minimal API
Zustand stores are created with the create function, which takes a callback that returns the initial state and actions. The callback receives set and get functions to update and read state. This section walks through a simple counter store, then a more realistic user preferences store. The API is intentionally minimal: no action types, no reducers, no dispatch. You just call set with a partial state or a function that returns a partial state. This pattern is intuitive and scales well. We'll also show how to access the store outside React (e.g., in a service or utility) using useStore.getState() and useStore.setState(). This is a common need in production for logging, analytics, or imperative updates.
set — the UI didn't update. Enforce immutability with lint rules or use Immer middleware.create returns a hook; set merges state shallowly, similar to React's setState.Selectors: Avoiding Unnecessary Re-renders
By default, Zustand re-renders a component whenever any part of the store changes. To optimize, use selectors. A selector is a function that extracts a slice of state. Zustand's useStore hook accepts an optional selector function and an optional equality function. This section explains how to use selectors to prevent re-renders when unrelated state changes. We'll also cover the shallow utility for comparing objects. In production, failing to use selectors can cause performance issues in large stores. We'll show a real example: a store with user data and UI state, where the user profile component should only re-render when user data changes, not when a modal toggles.
state.user is an object), the selector will trigger re-render on every store change. Use shallow or a custom equality function.shallow equality prevent re-renders when unrelated state changes.Actions and Async Logic: Keeping It Simple
Zustand actions are just functions that call set. For async logic, you can define async actions directly in the store. No middleware needed. This section shows how to fetch data from an API and update the store. We'll also discuss error handling and loading states. The pattern is straightforward: set loading to true, await the fetch, then set data and loading to false. We'll also cover how to call actions from outside React (e.g., in a service) using useStore.getState().fetchData(). This is useful for background sync or WebSocket handlers. We'll warn against common pitfalls like race conditions and stale closures, and show how to use get to access current state inside async actions.
get() to read current state instead of closures, which can become stale.set — no middleware required.Middleware: Persistence, Immer, and Devtools
Zustand supports middleware to extend functionality. The most common are persist (localStorage/sessionStorage), immer (immutable updates with mutable syntax), and devtools (Redux DevTools integration). This section explains how to apply middleware using create with a middleware wrapper. We'll show a production example: persisting user preferences to localStorage, using Immer for complex nested state updates, and connecting to Redux DevTools for debugging. We'll also discuss the order of middleware (e.g., persist should be outermost). We'll warn about the pitfalls of persisting sensitive data and the need to handle versioning when the store shape changes.
persist and immer add powerful features without bloat.Testing Zustand Stores: Unit Tests That Matter
Testing state management is critical. Zustand stores are plain functions, making them easy to test without React. This section shows how to write unit tests for store actions and selectors using Jest or Vitest. We'll test async actions by mocking fetch, and verify state transitions. We'll also test selectors for correct memoization. We'll discuss the importance of testing error states and edge cases. In production, untested stores lead to regressions when refactoring. We'll provide a complete test example with mocking and assertions.
beforeEach to avoid test pollution.Scaling Zustand: Slicing Stores and Combining
As your app grows, a single store becomes unwieldy. Zustand encourages splitting stores by domain. This section shows how to create multiple stores and combine them in components. We'll also discuss cross-store communication: one store can subscribe to another using subscribe or by calling actions from another store. We'll cover the pattern of a root store that composes slices, but warn against over-engineering. In production, we've seen teams create 50+ stores without issues, as long as each store is focused. We'll also show how to use useStore with multiple stores in a single component.
Performance Pitfalls and How to Avoid Them
Even with selectors, Zustand can cause performance issues if misused. This section covers common pitfalls: creating new objects in selectors, subscribing to large slices, and using inline functions that break memoization. We'll show how to use useShallow from zustand/shallow for deep equality, and how to use subscribe for side effects without re-renders. We'll also discuss the cost of set with large state trees and recommend using Immer for efficient updates. In production, we've seen apps where a single store update caused 100+ components to re-render due to missing selectors. We'll provide a checklist for performance optimization.
useShallow and stable selectors to prevent unnecessary re-renders.Zustand with TypeScript: Type Safety Without Pain
Zustand has excellent TypeScript support. This section shows how to type the store, actions, and selectors. We'll define an interface for the state and actions, and use create with the type parameter. We'll also show how to type middleware like persist and immer. We'll discuss the StateCreator type for reusable store patterns. In production, TypeScript catches bugs like misspelled action names or wrong payload types. We'll provide a complete typed store example and show how to use useStore with selectors that infer types.
Real-World Patterns: Authentication and Caching
This section applies Zustand to common production scenarios: authentication state and API caching. For auth, we'll create a store that holds user, token, and login/logout actions, with persistence to localStorage. For caching, we'll implement a simple cache store that stores API responses with timestamps and TTL. We'll discuss how to handle token refresh and cache invalidation. These patterns are battle-tested in production. We'll also show how to use Zustand's subscribe to sync auth state with other parts of the app (e.g., redirect on logout).
Migrating from Redux to Zustand: A Step-by-Step Guide
If you're considering migrating from Redux, this section provides a practical roadmap. We'll show how to map Redux concepts to Zustand: reducers become actions, selectors remain similar, and middleware like thunks become async actions. We'll demonstrate a migration of a simple todo app. We'll discuss strategies: incremental migration (one slice at a time) vs big bang. We'll also cover how to handle side effects (e.g., sagas) — in Zustand, you can use subscribe or call actions from within other actions. We'll warn about common migration pitfalls like forgetting to remove Provider wrappers or mis-handling middleware.
When Not to Use Zustand: Alternatives and Trade-offs
Zustand is not a silver bullet. This section discusses scenarios where other tools might be better: for server state, use TanStack Query; for complex forms, use Formik or React Hook Form; for highly concurrent state with undo/redo, consider XState. We'll also compare Zustand with Jotai and Recoil (atomic state) and Context API (for low-frequency updates). We'll give honest trade-offs: Zustand lacks built-in support for computed properties (use selectors or external libraries), and its middleware ecosystem is smaller than Redux's. In production, choosing the right tool is critical. We'll provide a decision tree.
Zustand with TypeScript: Full Type Safety
TypeScript integration in Zustand is seamless and provides full type safety without extra boilerplate. Define your store's state and actions as a type or interface, then pass it to create. For example:
```typescript import { create } from 'zustand';
interface BearStore { bears: number; increase: () => void; reset: () => void; }
const useBearStore = create<BearStore>((set) => ({ bears: 0, increase: () => set((state) => ({ bears: state.bears + 1 })), reset: () => set({ bears: 0 }), })); ```
This ensures that useBearStore returns correctly typed state and actions. For slices, use StateCreator to type each slice. Middleware like persist also supports generics for the storage type. TypeScript catches errors at compile time, reducing runtime bugs. Use unknown for external data and validate with Zod if needed.
as const for action types if needed.Zustand Persist Middleware: localStorage and Cookies
The persist middleware in Zustand allows you to save and rehydrate store state to storage like localStorage, sessionStorage, or cookies. To use it, wrap your store creation with persist and specify a name (key) and optional storage (default is localStorage). Example:
```typescript import { create } from 'zustand'; import { persist } from 'zustand/middleware';
interface AuthStore { token: string | null; setToken: (token: string) => void; logout: () => void; }
const useAuthStore = create<AuthStore>()( persist( (set) => ({ token: null, setToken: (token) => set({ token }), logout: () => set({ token: null }), }), { name: 'auth-storage', storage: { getItem: (name) => { const value = localStorage.getItem(name); return value ? JSON.parse(value) : null; }, setItem: (name, value) => { localStorage.setItem(name, JSON.stringify(value)); }, removeItem: (name) => localStorage.removeItem(name), }, } ) ); ```
For cookies, use a custom storage that reads/writes cookies. You can also use partialize to persist only specific fields. The middleware automatically rehydrates on app load. For SSR, ensure storage is only accessed on the client side.
partialize to persist only non-sensitive fields and consider encrypting stored data. For SSR, check typeof window !== 'undefined' before accessing storage.Zustand vs Jotai vs Context: Choosing the Right Tool
Zustand, Jotai, and React Context each solve state management differently. Zustand is a minimal, external store with a single store (or multiple slices) and subscriptions. It's great for global state, cross-component communication, and when you need middleware like persistence. Jotai is atomic: each piece of state is an atom, and components subscribe to atoms directly. It's ideal for fine-grained reactivity and avoids unnecessary re-renders without selectors. React Context is built-in but causes re-renders for all consumers when any part of the context value changes. It's best for low-frequency updates like themes or authentication status.
- Boilerplate: Zustand minimal, Jotai minimal, Context moderate.
- Performance: Zustand requires selectors to avoid re-renders; Jotai automatically; Context can cause performance issues.
- Scalability: Zustand slices well; Jotai scales with atoms; Context can become messy.
- Middleware: Zustand has rich middleware; Jotai has utilities; Context none.
Choose Zustand for global state with complex logic, Jotai for fine-grained reactivity, and Context for simple, low-frequency state.
| File | Command / Code | Purpose |
|---|---|---|
| install.sh | npm install zustand | Why Zustand? The Problem with Boilerplate State Management |
| store.js | const useStore = create((set) => ({ | Creating Your First Store |
| useUser.js | const useStore = create((set) => ({ | Selectors |
| store.js | const useStore = create((set, get) => ({ | Actions and Async Logic |
| store.js | const useStore = create( | Middleware |
| store.test.js | const useStore = create((set) => ({ | Testing Zustand Stores |
| stores.js | export const useUserStore = create((set) => ({ | Scaling Zustand |
| optimized.js | const { user, theme } = useStore((state) => ({ | Performance Pitfalls and How to Avoid Them |
| store.ts | interface BearState { | Zustand with TypeScript |
| authStore.js | const useAuthStore = create( | Real-World Patterns |
| migration.js | const todoReducer = (state = [], action) => { | Migrating from Redux to Zustand |
| bearStore.ts | interface BearStore { | Zustand with TypeScript |
| authStore.ts | interface AuthStore { | Zustand Persist Middleware |
| comparison.ts | const useStore = create((set) => ({ count: 0, increment: () => set((s) => ({ cou... | Zustand vs Jotai vs Context |
Key takeaways
create function that returns a hook, with no providers or reducers required.persist, immer, and devtools middleware without adding complexity.Common mistakes to avoid
3 patternsNot understanding React component re-rendering
Ignoring the rules of hooks
Mutating state directly instead of using setState
Interview Questions on This Topic
What is the Virtual DOM and how does React use it?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's React. Mark it forged?
6 min read · try the examples if you haven't