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.
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
| Middleware | Purpose |
|---|---|
devtools | Integrates with Redux DevTools for debugging |
persist | Automatically saves and hydrates state from storage |
subscribeWithSelector | Enables subscription to state changes using selectors |
combineReducers | Brings 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.
| Aspect | React Context | Zustand | Redux Toolkit |
|---|---|---|---|
| Re-render behavior | All consumers re-render | Only selected re-render | Optimized selectors |
| Bundle size | 0KB (built-in) | ~1KB | ~20KB |
| Boilerplate | Moderate | Minimal | Reduced with RTK |
| Middleware | Not supported | Built-in support | Extensive ecosystem |
| DevTools | Limited | Full integration | Full integration |
| Learning curve | Low | Low | Moderate |
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:
- Create new features using Zustand
- Migrate isolated state slices to Zustand
- Gradually replace Redux stores with Zustand stores
- 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 Case | Recommended Solution |
|---|---|
| Simple, infrequently updated state | React Context API |
| Enterprise apps with complex middleware | Redux Toolkit |
| Many interdependent atoms of state | Jotai/Recoil |
| Server state with caching | React Query/TanStack Query |
| Filterable or shareable state | URL 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
Sources
- pmndrs/zustand GitHub Repository - Official repository with documentation and examples
- State Management Trends in React 2025 - Makers Den - Comprehensive comparison of state management solutions
- State Management in 2025: When to Use Context, Redux, Zustand, or Jotai - DEV Community - Practical implementation patterns and benchmarks