How to Migrate from Redux to Zustand: Step-by-Step Guide
Zustand v5 weighs 0.5 KB gzip against Redux Toolkit + react-redux combined ~17 KB, and removes the Provider ceremony. This guide covers the full migration: install, mirror slices, swap components, and handle the middleware and DevTools gotchas.
By Ethan
2,116 words · 11 min read
For small-to-medium codebases — up to around 8 slices, no redux-saga, no heavy server-state orchestration — this migration pays off. Zustand v5.0.14 (stable as of May 28, 2026) weighs 0.5 KB gzip against Redux Toolkit’s 13.3 KB plus react-redux’s 3.7 KB — per Bundlephobia at these pinned versions. It removes the Provider boilerplate entirely and co-locates state and actions in a single file. The ROI flips once your Redux surface is large: a 30-slice app with saga middleware is a rewrite, not a refactor.
When NOT to migrate
Run through this checklist before touching a line of code:
- Large team (6+ engineers touching state daily). RTK’s slice pattern enforces structure that Zustand doesn’t. Without it, everyone’s store looks different and code review slows.
- You rely on time-travel debugging. Redux DevTools action replay is useful for specific bug classes. Zustand’s DevTools middleware shows state snapshots — not an action history you can rewind.
- You’re on redux-saga. Sagas express complex async flows (cancellation, race conditions, forking) that have no direct Zustand equivalent. Replacing them is possible but requires design work per saga, not a mechanical swap.
- Complex SSR hydration. RTK’s
configureStore+preloadedStatepattern is well-exercised with Next.js and Remix. Zustand’s per-store hydration is less battle-tested at scale. - Your codebase is working fine. No users are complaining about bundle size. The tests pass. The team knows the patterns. Rewriting state management adds zero user-visible value and carries real risk.
If none of those apply, proceed.
Zustand mental model in 30 seconds
Zustand is not a different way to organize the same Redux machinery. It drops the Redux architecture entirely: no reducers, no actions, no dispatch.
The Redux Toolkit counter slice:
// store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1 },
decrement: (state) => { state.value -= 1 },
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
},
},
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
The Zustand equivalent (official docs):
// store/useCounter.ts
import { create } from 'zustand'
interface CounterStore {
value: number
increment: () => void
decrement: () => void
incrementByAmount: (amount: number) => void
}
export const useCounter = create<CounterStore>((set) => ({
value: 0,
increment: () => set((state) => ({ value: state.value + 1 })),
decrement: () => set((state) => ({ value: state.value - 1 })),
incrementByAmount: (amount) => set((state) => ({ value: state.value + amount })),
}))
One file. No Provider. No action creator exports. Components import the hook directly. That’s the model throughout this guide.
Step 1: Install Zustand, keep Redux running
Don’t remove Redux yet. Run both during the migration — that’s the whole point of the slice-by-slice approach.
npm install zustand
Zustand v5.0.14 has zero runtime dependencies. That’s the only install step.
Failure mode: Confirm you installed v5.x, not a lower major. Run npm list zustand to verify — Zustand v5 requires Node >=12.20.0 per its engines field.
Step 2: Mirror one slice as a Zustand store
Pick your smallest, least-connected Redux slice. Create a Zustand store next to it with the same shape.
Say your smallest slice is uiSlice, managing a sidebar open/close state:
// store/uiSlice.ts — Redux, don't delete yet
import { createSlice } from '@reduxjs/toolkit'
const uiSlice = createSlice({
name: 'ui',
initialState: { sidebarOpen: false },
reducers: {
openSidebar: (state) => { state.sidebarOpen = true },
closeSidebar: (state) => { state.sidebarOpen = false },
toggleSidebar: (state) => { state.sidebarOpen = !state.sidebarOpen },
},
})
export const { openSidebar, closeSidebar, toggleSidebar } = uiSlice.actions
export default uiSlice.reducer
// store/useUI.ts — Zustand, new file
import { create } from 'zustand'
interface UIStore {
sidebarOpen: boolean
openSidebar: () => void
closeSidebar: () => void
toggleSidebar: () => void
}
export const useUI = create<UIStore>((set) => ({
sidebarOpen: false,
openSidebar: () => set({ sidebarOpen: true }),
closeSidebar: () => set({ sidebarOpen: false }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}))
Don’t remove the Redux slice. Don’t modify any components. Don’t touch the Provider. You’re only adding files at this step.
Failure mode: If you try to seed the Zustand store’s initial state from a Redux selector, stop — you’ll create a two-source-of-truth bug. The Zustand store initializes fresh. Any data you need at startup flows through normal initialization (API calls, URL params, localStorage) once you migrate the components.
Step 3: Swap component usage slice by slice
Find every component that reads from or dispatches to the Redux slice you mirrored. Replace those calls with the Zustand hook.
Before (Redux):
// Sidebar.tsx
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from './store'
import { toggleSidebar } from './store/uiSlice'
function Sidebar() {
const dispatch = useDispatch()
const sidebarOpen = useSelector((state: RootState) => state.ui.sidebarOpen)
return (
<aside className={sidebarOpen ? 'open' : 'closed'}>
<button onClick={() => dispatch(toggleSidebar())}>Toggle</button>
</aside>
)
}
After (Zustand):
// Sidebar.tsx
import { useUI } from './store/useUI'
function Sidebar() {
const { sidebarOpen, toggleSidebar } = useUI()
return (
<aside className={sidebarOpen ? 'open' : 'closed'}>
<button onClick={toggleSidebar}>Toggle</button>
</aside>
)
}
Do this for every component that touched the slice. Run the test suite after each component swap. Don’t proceed to the next slice until all tests pass.
Failure mode: Object destructuring from Zustand triggers a re-render check whenever any of the destructured values change by reference. For this example, toggleSidebar is a stable function reference and won’t cause extra re-renders. When you’re destructuring multiple state values (not actions) that change independently, use useShallow — covered in Gotchas below.
Step 4: Remove Redux when the last slice is migrated
Once every slice is mirrored in Zustand and every component uses the Zustand hook, remove Redux:
npm uninstall @reduxjs/toolkit react-redux
Then:
- Remove
<Provider store={store}>from your app root - Delete
store/index.tsand theRootState,AppDispatchtype exports - Delete the Redux slice files
- Remove any remaining
useSelectororuseDispatchimports
Run the full test suite. Check your bundle analyzer — you should see a ~16 KB gzip drop in the vendor chunk: [email protected] (13.3 KB) plus [email protected] (3.7 KB) removed, [email protected] (0.5 KB) added.
Failure mode: TypeScript still imports from deleted files? Check for barrel re-exports (index.ts files re-exporting slice actions). Delete those too. The compiler will tell you exactly what’s left.
Step 5: Handle middleware — thunks and sagas
Thunks
RTK’s createAsyncThunk maps cleanly to inline async functions in Zustand. No middleware needed:
// Before — RTK async thunk
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
export const fetchUser = createAsyncThunk('user/fetch', async (id: string) => {
const res = await fetch(`/api/users/${id}`)
return res.json()
})
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, error: null as string | null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => { state.loading = true })
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false
state.data = action.payload
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false
state.error = action.error.message ?? 'Unknown error'
})
},
})
// After — Zustand async action
import { create } from 'zustand'
interface UserStore {
data: User | null
loading: boolean
error: string | null
fetchUser: (id: string) => Promise<void>
}
export const useUser = create<UserStore>((set) => ({
data: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null })
try {
const res = await fetch(`/api/users/${id}`)
set({ data: await res.json(), loading: false })
} catch (err) {
set({ error: (err as Error).message, loading: false })
}
},
}))
Sagas
redux-saga has no equivalent in Zustand. For simple sagas that fire an API call and update state — convert them to inline async functions as shown above. For complex sagas that use takeLatest, race, channel, or fork, consider replacing the server-state part with TanStack Query and keeping plain Zustand for UI state. Don’t try to replicate saga orchestration inside Zustand stores; it fights the grain of the library.
Step 6: Wire up DevTools
Zustand DevTools use the same @redux-devtools/extension browser extension you already have installed. The middleware ships with Zustand:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface CounterStore {
value: number
increment: () => void
decrement: () => void
}
export const useCounter = create<CounterStore>()(
devtools(
(set) => ({
value: 0,
increment: () => set(
(state) => ({ value: state.value + 1 }),
false,
'counter/increment'
),
decrement: () => set(
(state) => ({ value: state.value - 1 }),
false,
'counter/decrement'
),
}),
{ name: 'Counter Store' }
)
)
The third argument to set() — 'counter/increment' — is the action label that appears in the DevTools extension. Without it, every state change shows as anonymous. Label your actions.
What you lose: time-travel. Redux DevTools lets you step backward through actions and replay state. Zustand’s DevTools shows state snapshots per labeled action, not a replayable action log. If time-travel is a real workflow for your team, that gap is significant.
Gotchas
1. Object selectors trigger extra re-renders — use useShallow
Zustand re-renders a component when the selected value changes by reference. Primitive selectors (state.value) work without any special handling. Object or array selectors need useShallow:
// Triggers a re-render on every state update — bad for object selections
const { value, label } = useCounter()
// Only re-renders when value or label changes
import { useShallow } from 'zustand/react/shallow'
const { value, label } = useCounter(
useShallow((state) => ({ value: state.value, label: state.label }))
)
Source: Zustand docs — prevent re-renders with useShallow, discussion #3103.
Actions (functions) returned by create() are stable references — they don’t cause extra re-renders on their own. useShallow is mainly needed when you destructure multiple state values that can change independently.
2. Derived state — selector functions or proxy-memoize
Zustand has no built-in equivalent to Redux’s createSelector. For simple derived values, pass a selector function directly:
// Recomputes only when the relevant state slice changes
const totalPrice = useCart(
(state) => state.items.reduce((sum, item) => sum + item.price * item.qty, 0)
)
For expensive computations over large arrays, use proxy-memoize:
import memoize from 'proxy-memoize'
const selectTotalPrice = memoize((state: CartStore) =>
state.items.reduce((sum, item) => sum + item.price * item.qty, 0)
)
const totalPrice = useCart(selectTotalPrice)
proxy-memoize caches based on which parts of the state object were actually accessed. It handles nested objects correctly without the manual dependency array that createSelector requires.
3. Testing — the store reset pattern
Without a Redux Provider, there’s no renderWithProviders wrapper to reset stores between tests. By default, Zustand stores persist state across test cases in the same process. Fix this with a mock that auto-resets after each test.
For Jest, create __mocks__/zustand.ts:
// __mocks__/zustand.ts (Jest)
import * as zustand from 'zustand'
import { act } from '@testing-library/react'
const { create: actualCreate } = jest.requireActual<typeof zustand>('zustand')
const storeResetFns = new Set<() => void>()
const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => store.setState(initialState, true))
return store
}) as typeof zustand.create
afterEach(() => act(() => storeResetFns.forEach((resetFn) => resetFn())))
export { create }
export * from 'zustand'
For Vitest, importActual is async:
// __mocks__/zustand.ts (Vitest)
import * as zustand from 'zustand'
import { act } from '@testing-library/react'
import { vi } from 'vitest'
const { create: actualCreate } = await vi.importActual<typeof zustand>('zustand')
const storeResetFns = new Set<() => void>()
const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => store.setState(initialState, true))
return store
}) as typeof zustand.create
afterEach(() => act(() => storeResetFns.forEach((resetFn) => resetFn())))
export { create }
export * from 'zustand'
Source: Zustand testing guide. The Jest vs Vitest difference — jest.requireActual vs await vi.importActual — is the most common point of failure when copying this pattern between projects.
Verdict
Migrate when your Redux codebase is small enough that the rewrite takes a week, not a quarter. Zustand v5.0.14 is stable, the bundle savings are real, and the API won’t surprise anyone who’s used React hooks for more than a month. Don’t migrate to chase the npm trend — migrate because the boilerplate is actually costing you time.
If you’re evaluating options for a new project rather than planning a migration, Zustand vs Redux Toolkit covers the comparison with benchmarks.