How to Migrate from Redux to Zustand: A Complete Guide for React Developers
You can migrate from Redux to Zustand by replacing createStore with Zustand's create function, eliminating action type constants in favor of direct set calls, and using the useStore hook instead of useSelector—all without needing a Provider wrapper.
Zustand is a tiny, unopinionated state-management library that replaces Redux with significantly less boilerplate while preserving core concepts like single-store architecture and immutable updates. This guide walks through the migration process using the actual implementation details from the pmndrs/zustand repository.
Step-by-Step Migration Strategy
Replace Redux Store Creation with Zustand's create
In Redux, you initialize state using createStore(reducer, preloadedState). In Zustand, you use the create function exported from the library, which is implemented in src/vanilla.ts【/src/vanilla.ts】.
// Redux approach
import { createStore } from 'redux'
const store = createStore(reducer, { count: 0 })
// Zustand approach
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
increment: (n) => set((state) => ({ count: state.count + n })),
decrement: (n) => set((state) => ({ count: state.count - n })),
}))
The create function builds a store with a setState method that performs shallow merges, eliminating the need for combineReducers or switch statements.
Remove Action Type Constants and Action Creators
Redux requires dispatching action objects with type constants. Zustand allows direct state updates via the set function provided in the store initializer.
// Redux: Action types and creators
const ADD_TODO = 'todos/add'
const addTodo = (text) => ({ type: ADD_TODO, payload: text })
store.dispatch(addTodo('Buy milk'))
// Zustand: Direct set calls
const useStore = create((set) => ({
todos: [],
addTodo: (text) => set((state) => ({
todos: [...state.todos, { text, id: Date.now() }]
})),
}))
If you prefer keeping the reducer pattern during migration, Zustand provides a redux middleware in src/middleware/redux.ts【/src/middleware/redux.ts】 that injects a dispatch method forwarding actions to your existing reducer.
Update Component Subscriptions
Redux uses useSelector and useDispatch hooks within a Provider context. Zustand's React binding in src/react.ts【/src/react.ts】 uses useSyncExternalStore under the hood, allowing components to subscribe directly to the store without a Provider wrapper.
// Redux component
import { useSelector, useDispatch } from 'react-redux'
const Counter = () => {
const count = useSelector((state) => state.count)
const dispatch = useDispatch()
return <button onClick={() => dispatch({ type: 'inc' })}>{count}</button>
}
// Zustand component
import { useCounter } from './store'
const Counter = () => {
const count = useCounter((s) => s.count)
const inc = useCounter((s) => s.inc)
return <button onClick={inc}>{count}</button>
}
Keeping the Reducer Pattern with Redux Middleware
For teams wanting to migrate incrementally, Zustand's redux middleware preserves your existing reducer logic while transitioning to Zustand's store model.
// zustandWithRedux.ts
import { create } from 'zustand'
import { redux } from 'zustand/middleware'
type State = { count: number }
type Action = { type: 'increment' | 'decrement'; amount: number }
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'increment':
return { count: state.count + action.amount }
case 'decrement':
return { count: state.count - action.amount }
default:
return state
}
}
export const useCounter = create(
redux(reducer, { count: 0 }) // Implementation in src/middleware/redux.ts
)
// Usage preserves Redux dispatch API
useCounter.getState().dispatch({ type: 'increment', amount: 5 })
This approach allows you to maintain existing action creators and reducer tests while benefiting from Zustand's smaller bundle size and simpler React integration.
Core Architectural Differences
Understanding these differences helps inform your migration strategy:
| Feature | Redux | Zustand |
|---|---|---|
| Store Creation | createStore(reducer, preloadedState) outside React |
create(initializer) in src/vanilla.ts returns a bound store usable anywhere |
| State Updates | Pure reducers + dispatch(action) → new state |
set merges partial objects; optional redux middleware in src/middleware/redux.ts keeps reducer pattern |
| Middleware | applyMiddleware(...middlewares) |
Higher-order StateCreator functions (e.g., devtools, persist, redux) |
| React Integration | Provider + useSelector, useDispatch |
useStore(store, selector?) using useSyncExternalStore in src/react.ts; no Provider needed |
| DevTools | Requires composeWithDevTools setup |
devtools middleware connects automatically |
The official comparison documentation in docs/learn/getting-started/comparison.md【/docs/learn/getting-started/comparison.md】 provides additional context on performance characteristics and render optimization strategies.
Complete Migration Example
Here's a before-and-after comparison for a todo application:
Redux Implementation:
// store.ts
import { createStore } from 'redux'
type Todo = { id: number; text: string; done: boolean }
type State = { todos: Todo[] }
type Action =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: number }
const reducer = (state: State = { todos: [] }, action: Action): State => {
switch (action.type) {
case 'ADD_TODO':
return { todos: [...state.todos, { id: Date.now(), text: action.payload, done: false }] }
case 'TOGGLE_TODO':
return { todos: state.todos.map(t => t.id === action.payload ? { ...t, done: !t.done } : t) }
default:
return state
}
}
export const store = createStore(reducer)
Zustand Migration:
// store.ts
import { create } from 'zustand'
type Todo = { id: number; text: string; done: boolean }
interface TodoStore {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (id: number) => void
}
export const useTodoStore = create<TodoStore>((set) => ({
todos: [],
addTodo: (text) => set((state) => ({
todos: [...state.todos, { id: Date.now(), text, done: false }]
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map(t => t.id === id ? { ...t, done: !t.done } : t)
})),
}))
Summary
- Store initialization moves from
createStore(reducer)tocreate(initializer)insrc/vanilla.ts, eliminating the need forcombineReducersand Provider wrappers. - State updates replace
dispatch(action)with directsetcalls that merge partial state, though thereduxmiddleware insrc/middleware/redux.tspreserves existing reducer logic during migration. - React integration switches from
useSelectoranduseDispatchtouseStore(store, selector?)implemented insrc/react.tsusinguseSyncExternalStore, removing the need for context providers. - Middleware architecture shifts from Redux's
applyMiddlewareto higher-orderStateCreatorfunctions that wrap the store initializer.
Frequently Asked Questions
Can I keep my existing Redux reducers when migrating to Zustand?
Yes. Zustand provides a redux middleware in src/middleware/redux.ts that accepts your existing reducer function and initial state, injecting a dispatch method into the store. This allows you to maintain your current action creators and reducer tests while transitioning to Zustand's simpler store model.
Do I need to wrap my application in a Provider when using Zustand?
No. Unlike Redux, which requires a Provider component to pass the store via React context, Zustand stores are created as module-level exports that can be imported directly into components. The React binding in src/react.ts uses useSyncExternalStore to subscribe to the store without context overhead.
How do I handle asynchronous logic when migrating from Redux Thunk?
Zustand does not require a separate middleware for asynchronous actions. Instead, you can define async functions directly in your store actions using the set parameter. For complex async flows, you can use the devtools middleware to track async state changes in Redux DevTools, or implement your own middleware pattern following the higher-order StateCreator pattern used in src/middleware/redux.ts.
Is Zustand's performance better than Redux for large applications?
According to the comparison documentation in docs/learn/getting-started/comparison.md, Zustand achieves similar or better performance through its selector-based subscription model. Components only re-render when the specific slice of state they select changes, similar to Redux's useSelector but without the context provider overhead. The useSyncExternalStore implementation in src/react.ts ensures consistent behavior with React's concurrent features.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →