· redux / zustand / react

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

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 + preloadedState pattern 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.ts and the RootState, AppDispatch type exports
  • Delete the Redux slice files
  • Remove any remaining useSelector or useDispatch imports

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.

References