How to Migrate from Redux to Zustand or Jotai

Type: Software Reference Confidence: 0.90 Sources: 8 Verified: 2026-02-23 Freshness: quarterly

TL;DR

Constraints

Quick Reference

Redux PatternZustand EquivalentJotai Equivalent
configureStore({ reducer })create((set) => ({ ... }))atom(initialValue) — no store needed
createSlice({ reducers })Actions defined inline: inc: () => set(...)Write atoms: atom(null, (get, set, arg) => ...)
useSelector(s => s.slice.value)useStore(s => s.value)useAtomValue(myAtom)
useDispatch() + dispatch(action())useStore(s => s.actionName)() — call directlyuseSetAtom(writeAtom)(arg)
createAsyncThunkAsync function inside store: set({ loading: true }); await ...Async write atom: atom(null, async (get, set) => ...)
createSelector (reselect)Selector function in hook: useStore(s => s.users.filter(...))Derived atom: atom(get => get(usersAtom).filter(...))
<Provider store={store}>No provider needed<Provider> optional (for scoping)
redux-persistBuilt-in: persist((set) => (...), { name: 'key' })atomWithStorage('key', defaultVal)
Redux DevTools (built-in)devtools((set) => (...)) middlewareuseAtomsDevtools('label')
Immer (built-in in RTK)immer((set) => (...)) middlewareNot needed — atoms are granular
combineReducers / slicesSlices pattern or multiple storesCompose atoms — each atom is independent
Middleware chaincreate(devtools(persist(immer(...))))Atom utilities: atomWithStorage, atomWithReducer
extraReducers (action listeners)subscribe(listener) on storeonMount atom effect
store.getState() (outside React)useStore.getState()store.get(myAtom) (vanilla store)

Decision Tree

START
├── Do you need a single centralized store (like Redux)?
│   ├── YES → Choose Zustand (closest mental model to Redux)
│   └── NO ↓
├── Do you have many independent pieces of state (form fields, toggles, filters)?
│   ├── YES → Choose Jotai (atomic model, fine-grained re-renders)
│   └── NO ↓
├── Do you rely heavily on Redux middleware (thunks, sagas, RTK Query)?
│   ├── YES → Keep RTK Query for server cache, migrate client state to Zustand
│   └── NO ↓
├── Is your Redux store <5 slices with simple CRUD?
│   ├── YES → Zustand (simplest migration path, 1:1 mapping)
│   └── NO ↓
├── Do you need Suspense integration for async state?
│   ├── YES → Jotai (native Suspense support via async atoms)
│   └── NO ↓
├── Is your project using React 17 or earlier?
│   ├── YES → Jotai 2.x (supports React 17+) or Zustand 4.x
│   └── NO ↓
└── DEFAULT → Zustand for global/shared state, Jotai for component-local atomic state

Step-by-Step Guide

1. Audit your Redux usage

Map every Redux slice, thunk, selector, and middleware. Identify which slices are “client state” (UI, forms, auth) vs “server cache” (API data). Server cache is better handled by TanStack Query. [src4]

# Count Redux usage patterns
grep -rn "createSlice\|createAsyncThunk\|useSelector\|useDispatch" --include='*.ts' --include='*.tsx' | wc -l
grep -rn "configureStore\|combineReducers" --include='*.ts' --include='*.tsx'
grep -rn "redux-persist\|redux-saga\|redux-thunk" package.json

Verify: Produce a list of all slices with their state shape, action count, and whether they manage client state or server cache.

2. Install Zustand or Jotai alongside Redux

Both libraries coexist with Redux — no conflicts. Install without removing Redux. [src1, src3]

# For Zustand:
npm install zustand
# Optional middleware:
npm install immer

# For Jotai:
npm install jotai

Verify: import { create } from 'zustand' or import { atom } from 'jotai' compiles without errors.

3. Migrate one Redux slice to Zustand

Pick the simplest slice first. Convert reducers to inline actions on a Zustand store. Remove the slice from combineReducers after migration. [src4, src5]

// BEFORE: Redux Toolkit slice
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0, history: [] as number[] },
  reducers: {
    increment: (state) => { state.value += 1; },
    decrement: (state) => { state.value -= 1; },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
      state.history.push(state.value);
    },
  },
});

// AFTER: Zustand store
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface CounterState {
  value: number;
  history: number[];
  increment: () => void;
  decrement: () => void;
  incrementByAmount: (amount: number) => void;
}

const useCounterStore = create<CounterState>()(
  devtools((set) => ({
    value: 0,
    history: [],
    increment: () => set((s) => ({ value: s.value + 1 })),
    decrement: () => set((s) => ({ value: s.value - 1 })),
    incrementByAmount: (amount) =>
      set((s) => ({
        value: s.value + amount,
        history: [...s.history, s.value + amount],
      })),
  }), { name: 'CounterStore' })
);

Verify: Replace useSelector and useDispatch in one component with the Zustand hook. Confirm identical behavior.

4. Migrate one Redux slice to Jotai (alternative path)

If choosing Jotai, decompose the Redux slice into individual atoms. Each piece of state becomes its own atom. [src2, src7]

import { atom } from 'jotai';

// Primitive atoms (replace Redux state fields)
export const countAtom = atom(0);
export const historyAtom = atom<number[]>([]);

// Write atoms (replace Redux actions)
export const incrementAtom = atom(null, (get, set) => {
  set(countAtom, get(countAtom) + 1);
});

export const incrementByAmountAtom = atom(null, (get, set, amount: number) => {
  const newValue = get(countAtom) + amount;
  set(countAtom, newValue);
  set(historyAtom, [...get(historyAtom), newValue]);
});

// Derived atom (replace Redux selector)
export const countSummaryAtom = atom((get) => ({
  current: get(countAtom),
  total: get(historyAtom).length,
}));

Verify: Replace useSelector with useAtomValue and useDispatch with useSetAtom. Confirm identical behavior.

5. Migrate async operations (thunks)

Replace createAsyncThunk with async functions inside Zustand stores or async write atoms in Jotai. [src3, src4]

// AFTER: Zustand async action
const useUserStore = create<UserState>()(
  devtools((set) => ({
    users: [],
    loading: false,
    error: null,
    fetchUsers: async () => {
      set({ loading: true, error: null });
      try {
        const res = await fetch('/api/users');
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const users = await res.json();
        set({ users, loading: false });
      } catch (err) {
        set({ error: (err as Error).message, loading: false });
      }
    },
  }), { name: 'UserStore' })
);

Verify: Network requests and loading/error states behave identically to the Redux version.

6. Replace Redux persistence

Swap redux-persist with built-in persistence middleware. [src3, src6]

// Zustand persist
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useAuthStore = create(
  persist(
    (set) => ({
      token: null,
      user: null,
      login: async (credentials) => { /* ... */ },
      logout: () => set({ token: null, user: null }),
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({ token: state.token, user: state.user }),
    }
  )
);

// Jotai atomWithStorage
import { atomWithStorage } from 'jotai/utils';
const tokenAtom = atomWithStorage<string | null>('auth-token', null);

Verify: Refresh the page — persisted state rehydrates correctly from localStorage.

7. Handle SSR (Next.js) store isolation

Zustand stores are singletons by default. For SSR with Next.js, use createStore + React context pattern to prevent state leaking between requests. Zustand v5 also offers experimental unstable_ssrSafe middleware. [src6, src8]

// SSR-safe Zustand store for Next.js App Router
import { createStore } from 'zustand';
import { createContext, useContext, useRef } from 'react';

const createAppStore = () =>
  createStore((set) => ({
    count: 0,
    increment: () => set((s) => ({ count: s.count + 1 })),
  }));

type AppStore = ReturnType<typeof createAppStore>;
const AppStoreContext = createContext<AppStore | null>(null);

export function AppStoreProvider({ children }: { children: React.ReactNode }) {
  const storeRef = useRef<AppStore>(null);
  if (!storeRef.current) storeRef.current = createAppStore();
  return (
    <AppStoreContext.Provider value={storeRef.current}>
      {children}
    </AppStoreContext.Provider>
  );
}

Verify: Run next build && next start, open two browser tabs — state should be isolated per request.

8. Remove Redux and clean up

After all slices are migrated and tested, uninstall Redux and its dependencies. [src4]

npm uninstall @reduxjs/toolkit react-redux redux-persist redux-thunk redux-saga
# Verify no Redux references remain
grep -rn "redux\|createSlice\|useSelector\|useDispatch\|configureStore" --include='*.ts' --include='*.tsx'

Verify: grep -rn '@reduxjs\|react-redux' package.json returns zero results. App runs without Redux loaded.

Code Examples

TypeScript/React: Full Redux-to-Zustand migration (Todo app)

Full script: typescript-react-full-redux-to-zustand-migration-t.ts (77 lines)

// Input:  Redux Toolkit todo slice with CRUD + filter
// Output: Equivalent Zustand store with devtools + persist + immer

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

interface Todo { id: string; text: string; completed: boolean; }
type Filter = 'all' | 'active' | 'completed';

interface TodoState {
  todos: Todo[];
  filter: Filter;
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  removeTodo: (id: string) => void;
  setFilter: (filter: Filter) => void;
  filteredTodos: () => Todo[];
}

const useTodoStore = create<TodoState>()(
  devtools(
    persist(
      immer((set, get) => ({
        todos: [],
        filter: 'all' as Filter,
        addTodo: (text) => set((state) => {
          state.todos.push({ id: crypto.randomUUID(), text, completed: false });
        }),
        toggleTodo: (id) => set((state) => {
          const todo = state.todos.find((t) => t.id === id);
          if (todo) todo.completed = !todo.completed;
        }),
        removeTodo: (id) => set((state) => {
          state.todos = state.todos.filter((t) => t.id !== id);
        }),
        setFilter: (filter) => set({ filter }),
        filteredTodos: () => {
          const { todos, filter } = get();
          switch (filter) {
            case 'active': return todos.filter((t) => !t.completed);
            case 'completed': return todos.filter((t) => t.completed);
            default: return todos;
          }
        },
      })),
      { name: 'todo-storage' }
    ),
    { name: 'TodoStore' }
  )
);

TypeScript/React: Full Redux-to-Jotai migration (Todo app)

Full script: typescript-react-full-redux-to-jotai-migration-tod.ts (60 lines)

// Input:  Redux Toolkit todo slice with CRUD + filter
// Output: Equivalent Jotai atoms with derived state

import { atom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

interface Todo { id: string; text: string; completed: boolean; }
type Filter = 'all' | 'active' | 'completed';

// Primitive atoms (replace Redux state)
const todosAtom = atomWithStorage<Todo[]>('todos', []);
const filterAtom = atom<Filter>('all');

// Write atoms (replace Redux actions)
const addTodoAtom = atom(null, (get, set, text: string) => {
  set(todosAtom, [...get(todosAtom), {
    id: crypto.randomUUID(), text, completed: false,
  }]);
});

const toggleTodoAtom = atom(null, (get, set, id: string) => {
  set(todosAtom, get(todosAtom).map((t) =>
    t.id === id ? { ...t, completed: !t.completed } : t
  ));
});

// Derived atom (replaces createSelector)
const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom);
  const filter = get(filterAtom);
  switch (filter) {
    case 'active': return todos.filter((t) => !t.completed);
    case 'completed': return todos.filter((t) => t.completed);
    default: return todos;
  }
});

TypeScript/React: Gradual coexistence — Redux + Zustand side by side

Full script: typescript-react-gradual-coexistence-redux-zustand.ts (35 lines)

// Input:  App with Redux Provider, migrating one slice at a time
// Output: Both Redux and Zustand running simultaneously

import { Provider } from 'react-redux';
import { store } from './store/reduxStore'; // remaining Redux slices
import useAuthStore from './store/useAuthStore'; // migrated to Zustand

// Redux Provider stays until all slices are migrated
function App() {
  return (
    <Provider store={store}>
      <Header />   {/* Uses Zustand (useAuthStore) */}
      <Dashboard /> {/* Uses Redux (useSelector/useDispatch) */}
    </Provider>
  );
}

// Migrated component — Zustand, no Provider needed
function Header() {
  const user = useAuthStore((s) => s.user);
  const logout = useAuthStore((s) => s.logout);
  return (
    <header>
      <span>{user?.name}</span>
      <button onClick={logout}>Logout</button>
    </header>
  );
}

Anti-Patterns

Wrong: Wrapping Zustand store in a Provider like Redux

// ❌ BAD — Zustand does not need a Provider
const StoreContext = createContext(null);
function StoreProvider({ children }) {
  const store = useMemo(() => create((set) => ({ count: 0 })), []);
  return (
    <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
  );
}

Correct: Use Zustand hooks directly — no Provider needed

// ✅ GOOD — Zustand stores are module-scoped, import and use directly
const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
}));

function Counter() {
  const count = useCounterStore((s) => s.count);
  const increment = useCounterStore((s) => s.increment);
  return <button onClick={increment}>{count}</button>;
}

Wrong: Selecting the entire Zustand store (causes unnecessary re-renders)

// ❌ BAD — Every state change re-renders this component
function UserProfile() {
  const store = useUserStore();  // selects everything
  return <span>{store.user.name}</span>;
}

Correct: Use granular selectors

// ✅ GOOD — Only re-renders when user.name changes
import { useShallow } from 'zustand/react/shallow';

function UserProfile() {
  const name = useUserStore((s) => s.user.name);
  return <span>{name}</span>;
}

// For multiple fields:
function UserCard() {
  const { name, email } = useUserStore(
    useShallow((s) => ({ name: s.user.name, email: s.user.email }))
  );
  return <div>{name} ({email})</div>;
}

Wrong: Mutating state directly in Zustand without Immer

// ❌ BAD — Direct mutation; Zustand won't detect the change
const useStore = create((set) => ({
  todos: [],
  addTodo: (todo) => set((state) => {
    state.todos.push(todo);  // Mutation! No new reference
    return state;
  }),
}));

Correct: Return new state objects, or use Immer middleware

// ✅ GOOD (option A) — Immutable update
const useStore = create((set) => ({
  todos: [],
  addTodo: (todo) => set((state) => ({
    todos: [...state.todos, todo],
  })),
}));

// ✅ GOOD (option B) — Immer middleware
import { immer } from 'zustand/middleware/immer';
const useStore = create(immer((set) => ({
  todos: [],
  addTodo: (todo) => set((state) => { state.todos.push(todo); }),
})));

Wrong: Creating atoms inside components (Jotai)

// ❌ BAD — New atom on every render, state is lost
function Counter() {
  const countAtom = atom(0);  // Re-created every render!
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

Correct: Define atoms at module scope

// ✅ GOOD — Atom defined once at module scope
const countAtom = atom(0);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

Wrong: Using Zustand v5 with object selectors without useShallow

// ❌ BAD — Zustand v5 uses Object.is; new object every render = infinite loop
function UserInfo() {
  const { name, email } = useUserStore((s) => ({
    name: s.name,
    email: s.email,
  })); // Creates new object each time
}

Correct: Use useShallow for object selectors in Zustand v5

// ✅ GOOD — useShallow compares properties shallowly
import { useShallow } from 'zustand/react/shallow';

function UserInfo() {
  const { name, email } = useUserStore(
    useShallow((s) => ({ name: s.name, email: s.email }))
  );
  return <div>{name} ({email})</div>;
}

Common Pitfalls

Diagnostic Commands

# Check Redux dependencies still in project
grep -rn "@reduxjs/toolkit\|react-redux\|redux-persist\|redux-saga" package.json

# Count remaining Redux usage in code
grep -rn "useSelector\|useDispatch\|createSlice\|createAsyncThunk" --include='*.ts' --include='*.tsx' | wc -l

# Check bundle impact (approximate gzipped sizes)
# zustand: ~3 KB | jotai: ~4 KB | @reduxjs/toolkit: ~14 KB

# Verify no duplicate state management providers
grep -rn "<Provider" --include='*.tsx' --include='*.jsx' | grep -v "node_modules"

# Check for atoms defined inside components (Jotai anti-pattern)
grep -rn "const.*Atom = atom(" --include='*.tsx' --include='*.ts' | grep -v "store\|atoms\|state"

# Check Zustand/Jotai versions installed
npm ls zustand jotai

Version History & Compatibility

LibraryCurrent VersionMin ReactKey ChangesNotes
Zustand 5.x5.0.11 (Feb 2025)React 18+Strict equality default, useShallow, experimental unstable_ssrSafeUse createWithEqualityFn if migrating from v4. Persist fix in v5.0.10.
Zustand 4.x4.5.5React 16.8+immer middleware, persist v2Upgrade to v5: remove deprecated equality fn
Jotai 2.x2.18.0 (Feb 2025)React 17+atomWithStorage, useAtomValue/useSetAtom, jotai/babeljotai-babelatomFamily deprecated, use jotai-family
Jotai 1.x1.13.1React 16.8+Initial stable releaseMigrate to v2 API
Redux Toolkit 2.x2.5.0 (Jan 2026)React 16.8+ESM-only, updated typesSource library (migrate from)

When to Use / When Not to Use

Use Zustand WhenUse Jotai WhenStay on Redux When
Migrating from Redux (closest mental model)Many independent UI states (form fields, toggles)Large team with established Redux patterns
Need one centralized store per domainNeed fine-grained re-render optimizationComplex middleware chains (sagas, epics)
Server-side rendering with Next.jsUsing React Suspense for async stateRegulatory/enterprise requirement for Redux
Small-medium app, rapid developmentTypeScript-heavy project with computed stateApplication already stable, no performance issues
Team familiar with Flux/Redux conceptsBottom-up state compositionRTK Query handles all server state needs

Important Caveats

Related Units