Zustand Adoption Guide

A complete introduction to modern React state management with the lightweight, powerful Zustand library

State management has always been one of the most critical decisions in React application architecture. For years, Redux dominated the landscape, but with the evolution of React and changing project requirements, developers have sought simpler, more lightweight alternatives. Zustand has emerged as a powerful contender, offering Redux-like capabilities with a fraction of the complexity and boilerplate.

This guide walks you through everything you need to know to adopt Zustand in your React projects, from basic concepts to advanced patterns and migration strategies. Whether you're building a new application or looking to modernize an existing codebase, understanding Zustand will help you build more maintainable React applications with less overhead as part of your /services/web-development/ strategy.

Why Choose Zustand?

Key benefits that make Zustand a compelling choice for modern React applications

Minimal Boilerplate

Create stores with a single function call, no actions, reducers, or dispatchers required

TypeScript-First

Excellent TypeScript support out of the box with minimal configuration

Flexible Architecture

Works with React and can be used outside component trees when needed

Middleware Support

Extend functionality with logging, persistence, and custom middleware

DevTools Integration

Built-in support for Redux DevTools with zero configuration

Small Bundle Size

Approximately 1KB gzipped, compared to Redux Toolkit's ~20KB

Getting Started: Installation and Basic Setup

Installing Zustand

npm install zustand
# or
yarn add zustand
# or
pnpm add zustand

Creating Your First Store

The fundamental unit of Zustand is the store. Unlike Redux, where you define actions and reducers separately, Zustand combines state and logic in a unified API:

import { create } from 'zustand'

interface BearState {
 bears: number
 increase: (by: number) => void
}

const useBearStore = create<BearState>((set) => ({
 bears: 0,
 increase: (by) => set((state) => ({ bears: state.bears + by })),
}))

This simple example demonstrates Zustand's core philosophy: state and the functions that modify it live together in a single, cohesive unit.

Core Concepts: Stores, State, and Actions

Understanding the Create Function

The create function is the heart of Zustand. It accepts a configuration function that returns an object containing your state and methods to modify it:

const useStore = create((set, get) => ({
 // State properties
 count: 0,
 user: null,
 todos: [],
 
 // Actions (methods that modify state)
 increment: () => set((state) => ({ count: state.count + 1 })),
 decrement: () => set((state) => ({ count: state.count - 1 })),
 setUser: (user) => set({ user }),
 addTodo: (todo) => set((state) => ({ 
 todos: [...state.todos, todo] 
 })),
 
 // Computed values through selectors
 doubleCount: () => get().count * 2,
}))

The Set Function: Updating State

Zustand's set function handles state updates with a simple, predictable API:

// Object syntax - merges with existing state
set({ count: 5 })

// Functional syntax - receives current state
set((state) => ({ count: state.count + 1 }))

Subscribing to State Changes

Components can subscribe to specific pieces of state using the store hook:

function Counter() {
 const count = useStore((state) => state.count)
 const increment = useStore((state) => state.increment)
 
 return (
 <button onClick={increment}>
 Count: {count}
 </button>
 )
}

Selectors and Derived State

Optimizing Re-Renders with Selectors

Selectors are functions that extract specific portions of state. By selecting only the data your component needs, you minimize unnecessary re-renders:

// Basic selector - extracts single value
const count = useStore((state) => state.count)

// Derived state - calculates values from state
const doubleCount = useStore((state) => state.count * 2)

// Object selector - extracts multiple values
const { count, user } = useStore((state) => ({ 
 count: state.count, 
 user: state.user 
}))

Creating Reusable Selectors

For complex applications, extracting selectors into reusable functions improves code organization:

// selectors.ts
export const selectCount = (state) => state.count
export const selectUser = (state) => state.user

export const selectTodosByStatus = (status) => (state) => 
 state.todos.filter((todo) => todo.status === status)

// Component usage
const count = useStore(selectCount)
const user = useStore(selectUser)

Shallow Comparison

When selecting objects or arrays, use shallow comparison to prevent unnecessary re-renders:

import { shallow } from 'zustand/shallow'

const { count, user } = useStore(
 (state) => ({ count: state.count, user: state.user }),
 shallow
)

Middleware: Extending Zustand's Capabilities

Middleware wraps the store creation process, allowing you to intercept state updates:

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

const useStore = create(
 devtools(
 persist(
 (set, get) => ({
 count: 0,
 increment: () => set((state) => ({ count: state.count + 1 })),
 }),
 { name: 'counter-storage' }
 ),
 { name: 'counter-store' }
 )
)

Built-in Middleware

MiddlewarePurpose
devtoolsIntegrates with Redux DevTools for debugging
persistAutomatically saves and hydrates state from storage
subscribeWithSelectorEnables subscription to state changes using selectors
combineReducersBrings Redux-style reducer composition to Zustand

Creating Custom Middleware

const loggingMiddleware = (config) => (set, get, api) => {
 const originalSet = api.setState
 
 api.setState = (...args) => {
 console.log('Previous state:', get())
 originalSet(...args)
 console.log('New state:', get())
 }
 
 return config(set, get, api)
}

TypeScript Integration: Best Practices

Defining Store Types

Properly typing your stores ensures type safety throughout your application:

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface Todo {
 id: string
 text: string
 completed: boolean
}

interface TodoState {
 todos: Todo[]
 filter: 'all' | 'active' | 'completed'
 addTodo: (text: string) => void
 toggleTodo: (id: string) => void
 setFilter: (filter: TodoState['filter']) => void
}

const useTodoStore = create<TodoState>()(
 devtools(
 persist(
 (set) => ({
 todos: [],
 filter: 'all',
 
 addTodo: (text) => set((state) => ({
 todos: [...state.todos, { 
 id: crypto.randomUUID(), 
 text, 
 completed: false 
 }]
 })),
 
 toggleTodo: (id) => set((state) => ({
 todos: state.todos.map((todo) =>
 todo.id === id 
 ? { ...todo, completed: !todo.completed}
 : todo
 )
 })),
 
 setFilter: (filter) => set({ filter }),
 }),
 { name: 'todo-storage' }
 )
 )
)

Generic Stores for Reusability

function createCounterStore(initialValue = 0) {
 return create((set) => ({
 value: initialValue,
 increment: () => set((state) => ({ value: state.value + 1 })),
 decrement: () => set((state) => ({ value: state.value - 1 })),
 reset: () => set({ value: initialValue }),
 }))
}

const useCounter = createCounterStore(0)
const useScore = createCounterStore(100)

Zustand's TypeScript support is one of its strongest features, making it an excellent choice for teams building robust, type-safe React applications. Combined with our /services/web-development/ expertise, this enables rapid development of maintainable state management solutions.

Zustand vs React Context vs Redux Toolkit
AspectReact ContextZustandRedux Toolkit
Re-render behaviorAll consumers re-renderOnly selected re-renderOptimized selectors
Bundle size0KB (built-in)~1KB~20KB
BoilerplateModerateMinimalReduced with RTK
MiddlewareNot supportedBuilt-in supportExtensive ecosystem
DevToolsLimitedFull integrationFull integration
Learning curveLowLowModerate

Zustand vs React Context

React Context's main limitation is that all consumers re-render when the context value changes, regardless of whether they use the specific data that changed. Zustand's selector-based subscription model prevents unnecessary re-renders.

Zustand vs Redux Toolkit

While Redux Toolkit significantly reduced Redux's boilerplate, Zustand offers a fundamentally simpler approach with a single file store definition instead of actions, reducers, and thunks. For teams already using Redux in React Native applications, migrating to Zustand can reduce complexity while maintaining similar debugging capabilities.

Zustand vs Jotai

Jotai takes an atomic approach where each piece of state is independent. Zustand is better suited for applications with unified state domains, while Jotai excels when you have many independent pieces of state with complex derived relationships.

Migration Strategies: Moving from Redux to Zustand

Incremental Migration Approach

Migrating from Redux to Zustand doesn't require a complete rewrite. An incremental approach allows you to adopt Zustand gradually while maintaining application stability:

  1. Create new features using Zustand
  2. Migrate isolated state slices to Zustand
  3. Gradually replace Redux stores with Zustand stores
  4. Remove Redux dependencies once migration is complete

This approach minimizes risk and allows your team to validate the migration on smaller components before committing to larger refactors. Our web development team has extensive experience guiding teams through this transition.

Converting a Redux Slice to Zustand

// Redux slice
const userSlice = createSlice({
 name: 'user',
 initialState: { profile: null, loading: false },
 reducers: {
 setProfile: (state, action) => { state.profile = action.payload },
 setLoading: (state, action) => { state.loading = action.payload },
 },
})

// Zustand equivalent
const useUserStore = create((set) => ({
 profile: null,
 loading: false,
 setProfile: (profile) => set({ profile }),
 setLoading: (loading) => set({ loading }),
}))

Using Zustand with Existing Redux

During migration, you can use Zustand alongside Redux stores without disruption. This useful for hybrid approach is particularly large codebases where a complete migration would be too disruptive.

Best Practices and Common Patterns

Organizing Zustand Stores

For larger applications, organizing stores by feature domain improves maintainability and makes it easier to scale your codebase:

// stores/
// ├── index.ts (exports all stores)
// ├── auth.ts
// ├── cart.ts
// └── preferences.ts

// stores/cart.ts
interface CartItem {
 id: string
 quantity: number
 product: Product
}

interface CartState {
 items: CartItem[]
 isOpen: boolean
 addItem: (item: CartItem) => void
 removeItem: (id: string) => void
 toggleCart: () => void
}

export const useCartStore = create<CartState>()(
 persist(
 (set) => ({
 items: [],
 isOpen: false,
 addItem: (item) => set((state) => ({
 items: [...state.items, item]
 })),
 removeItem: (id) => set((state) => ({
 items: state.items.filter((item) => item.id !== id)
 })),
 toggleCart: () => set((state) => ({ 
 isOpen: !state.isOpen 
 })),
 }),
 { name: 'cart-storage' }
 )
)

Testing Zustand Stores

import { renderHook, act } from '@testing-library/react'
import { create } from 'zustand'

function useCounterStore() {
 return create((set) => ({
 count: 0,
 increment: () => set((state) => ({ count: state.count + 1 })),
 }))
}

describe('Counter Store', () => {
 it('initializes with zero count', () => {
 const { result } = renderHook(() => useCounterStore())
 expect(result.current.count).toBe(0)
 })

 it('increments count', () => {
 const { result } = renderHook(() => useCounterStore())
 
 act(() => {
 result.current.increment()
 })
 
 expect(result.current.count).toBe(1)
 })
})

Performance Optimization

// Bad: Selecting entire state
const allState = useStore((state) => state)

// Good: Selecting specific needed values
const user = useStore((state) => state.user)

// Good: Using subscribe for non-React listeners
useStore.subscribe(
 (state) => state.count,
 (count) => {
 console.log('Count changed:', count)
 }
)

Pitfall 1: Selecting Too Much State

// Bad: Causes unnecessary re-renders
const userData = useStore((state) => ({
 id: state.user?.id,
 name: state.user?.name,
 email: state.user?.email,
}))

// Good: Select specific values
const userId = useStore((state) => state.user?.id)
const userName = useStore((state) => state.user?.name)

Pitfall 2: Mutating State Directly

// Bad: Direct mutation
const increment = () => {
 store.count += 1 // This won't trigger re-renders!
}

// Good: Using set
const increment = () => set((state) => ({ count: state.count + 1 }))

Pitfall 3: Overusing Middleware

// Bad: Overcomplicating with unnecessary middleware
const useStore = create(
 devtools(
 persist(
 loggingMiddleware(
 analyticsMiddleware(
 (set) => ({ count: 0 })
 )
 )
 )
 )
)

// Good: Add middleware only when needed
const useStore = create(
 persist(
 (set) => ({ count: 0 }),
 { name: 'storage' }
 )
)

When to Use Zustand (and When Not To)

Choose Zustand When:

  • Building medium to large React applications
  • Redux feels like overkill for your use case
  • You need good performance without complex optimization
  • You want TypeScript support without extensive configuration
  • Your team values simplicity and maintainability
  • You need to persist state across sessions
  • You want easy debugging with DevTools

Consider Alternatives When:

Use CaseRecommended Solution
Simple, infrequently updated stateReact Context API
Enterprise apps with complex middlewareRedux Toolkit
Many interdependent atoms of stateJotai/Recoil
Server state with cachingReact Query/TanStack Query
Filterable or shareable stateURL State

For most modern web applications, Zustand provides the right balance of simplicity and power. It's particularly well-suited for teams working on /services/web-development/ projects that need maintainable state management without the overhead of more complex solutions.

Frequently Asked Questions

Ready to Modernize Your React State Management?

Our team of React experts can help you adopt Zustand or guide you to the right state management solution for your project.

Sources

  1. pmndrs/zustand GitHub Repository - Official repository with documentation and examples
  2. State Management Trends in React 2025 - Makers Den - Comprehensive comparison of state management solutions
  3. State Management in 2025: When to Use Context, Redux, Zustand, or Jotai - DEV Community - Practical implementation patterns and benchmarks