---
# === IDENTITY ===
id: software/migrations/redux-to-zustand/2026
canonical_question: "How do I migrate from Redux to Zustand or Jotai?"
aliases:
  - "Redux to Zustand migration guide"
  - "replace Redux with Zustand"
  - "Redux to Jotai migration"
  - "Redux alternative state management"
  - "migrate from Redux Toolkit to Zustand"
  - "switch from Redux to Jotai atoms"
  - "Redux vs Zustand vs Jotai migration"
entity_type: software_reference
domain: software > migrations > redux_to_zustand
region: global
jurisdiction: global
temporal_scope: 2022-2026

# === VERIFICATION ===
last_verified: 2026-05-29
confidence: 0.90
version: 2.1
first_published: 2026-02-17

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: evolving
  last_breaking_change: "Zustand 5.0 (2024-10)"
  next_review: 2026-11-25
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "Zustand v5 requires React 18+ — use Zustand 4.x or choose Jotai for React 17 apps"
  - "Never migrate all Redux slices at once — use incremental slice-by-slice migration to avoid regressions"
  - "Zustand middleware order matters: devtools outermost, immer innermost — wrong order breaks DevTools"
  - "Never let Zustand and Redux manage the same state — keep clear ownership boundaries during coexistence"
  - "Zustand persist stores in localStorage by default — configure partialize to exclude secrets or use sessionStorage for tokens"
  - "Jotai atoms must be defined at module scope — defining inside components causes state loss on every render"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User wants to migrate server state / API caching (not client state)"
    use_instead: "Use TanStack Query (React Query) for server state — Zustand/Jotai are for client state only"
  - condition: "User wants to migrate between Redux versions (e.g., legacy Redux to Redux Toolkit)"
    use_instead: "See Redux official migration guide: redux.js.org/usage/migrating-to-modern-redux"
  - condition: "User needs React class component state management (not hooks-based)"
    use_instead: "software/migrations/react-classes-to-hooks/2026"

# === AGENT HINTS ===
inputs_needed:
  - key: target_library
    question: "Which library do you want to migrate to?"
    type: choice
    options: ["Zustand", "Jotai", "Not sure — help me decide"]
  - key: redux_complexity
    question: "How complex is your Redux setup?"
    type: choice
    options: ["Simple (<5 slices, no middleware)", "Moderate (5-15 slices, thunks/persist)", "Complex (sagas, epics, custom middleware)"]
  - key: react_version
    question: "Which React version is the project using?"
    type: choice
    options: ["React 18+", "React 17", "React 16"]

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/migrations/redux-to-zustand/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-05-29)"

# === RELATED UNITS ===
related_kos:
  related_to:
    - id: "software/migrations/jquery-to-react/2026"
      label: "jQuery to React Migration"
    - id: "software/migrations/javascript-to-typescript/2026"
      label: "JavaScript to TypeScript Migration"
    - id: "software/migrations/webpack-to-vite/2026"
      label: "Webpack to Vite Migration"
  solves:
    - id: "software/migrations/react-classes-to-hooks/2026"
      label: "React Class Components to Hooks"
  often_confused_with:
    - id: "software/migrations/react-classes-to-hooks/2026"
      label: "React Class to Hooks (different scope — that is component patterns, this is state management)"

# === SOURCES (8 authoritative sources) ===
# Types: official_docs, technical_blog, rfc_spec, academic_paper, community_resource, industry_report
# Reliability: high, moderate_high, moderate, moderate_low, low, authoritative
sources:
  - id: src1
    title: "Zustand Documentation — Comparison with Redux"
    author: Poimandres (pmndrs)
    url: https://zustand.docs.pmnd.rs/getting-started/comparison
    type: official_docs
    published: 2025-10-01
    reliability: authoritative
  - id: src2
    title: "Jotai Documentation — Comparison with Other Libraries"
    author: Poimandres (pmndrs)
    url: https://jotai.org/docs/basics/comparison
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src3
    title: "Zustand vs. Redux Toolkit vs. Jotai — Complete Comparison"
    author: Better Stack
    url: https://betterstack.com/community/guides/scaling-nodejs/zustand-vs-redux-toolkit-vs-jotai/
    type: technical_blog
    published: 2025-09-15
    reliability: high
  - id: src4
    title: "Migrating from Redux to Zustand: Step-by-Step Guide"
    author: TillItsDone
    url: https://tillitsdone.com/blogs/redux-to-zustand-migration-guide/
    type: technical_blog
    published: 2025-08-20
    reliability: high
  - id: src5
    title: "How to best approach moving from Redux to Zustand?"
    author: pmndrs/zustand Community
    url: https://github.com/pmndrs/zustand/discussions/1461
    type: community_resource
    published: 2023-06-01
    reliability: moderate_high
  - id: src6
    title: "Zustand v5 Migration Guide (How to Migrate to v5 from v4)"
    author: Poimandres (pmndrs)
    url: https://zustand.docs.pmnd.rs/reference/migrations/migrating-to-v5
    type: official_docs
    published: 2026-05-28
    reliability: authoritative
  - id: src7
    title: "Jotai Redux Extension — atomWithStore"
    author: Poimandres (pmndrs)
    url: https://jotai.org/docs/extensions/redux
    type: official_docs
    published: 2026-05-06
    reliability: authoritative
  - id: src8
    title: "Zustand vs Redux Toolkit: Which should you use in 2026?"
    author: Sangram Patra
    url: https://medium.com/@sangramkumarp530/zustand-vs-redux-toolkit-which-should-you-use-in-2026-903304495e84
    type: technical_blog
    published: 2025-11-01
    reliability: moderate_high
---

# How to Migrate from Redux to Zustand or Jotai

## How do I migrate from Redux to Zustand or Jotai?

## TL;DR

- **Bottom line**: Replace Redux slices one at a time with Zustand stores (single-store, action-based) or Jotai atoms (atomic, bottom-up) — both coexist with Redux during migration, require no Provider wrapper (Zustand) or minimal setup (Jotai), and cut boilerplate by 60-80%.
- **Key tool/command**: `const useStore = create((set) => ({ count: 0, inc: () => set(s => ({ count: s.count + 1 })) }))`
- **Watch out for**: Migrating the entire Redux store at once — migrate slice-by-slice, keeping both libraries running side-by-side until each slice is verified.
- **Works with**: Zustand 5.0.14 (React 18+, TS 4.5+), Jotai 2.20.0 (React 17+), Redux Toolkit 2.x. Compatible with TypeScript 5+, Next.js 14+/15, Vite, Webpack.

## Constraints
<!-- Agents: read this section before recommending any action from this unit.
     These are hard constraints that must not be violated. -->

- Zustand v5 requires React 18+ — if your app uses React 17 or earlier, use Zustand 4.x or choose Jotai instead. [src6]
- Never migrate all Redux slices at once — use incremental slice-by-slice migration to prevent regressions and stalled feature development. [src4]
- Zustand middleware must be ordered correctly: `create(devtools(persist(immer(...))))` — devtools outermost, immer innermost. Wrong order breaks DevTools. [src6]
- Never let Zustand and Redux manage the same state simultaneously — keep clear ownership boundaries for each slice during coexistence. [src5]
- Zustand's `persist` middleware uses localStorage by default — configure `partialize` to exclude sensitive data (tokens, secrets) or use `sessionStorage`. [src3]
- Jotai atoms must be defined at module scope — defining atoms inside React components recreates them every render, silently losing all state. [src2]

## Quick Reference

| Redux Pattern | Zustand Equivalent | Jotai 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)` — direct selector | `useAtomValue(myAtom)` |
| `useDispatch()` + `dispatch(action())` | `useStore(s => s.actionName)()` — call directly | `useSetAtom(writeAtom)(arg)` |
| `createAsyncThunk` | Async 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-persist` | Built-in: `persist((set) => (...), { name: 'key' })` | `atomWithStorage('key', defaultVal)` |
| Redux DevTools (built-in) | `devtools((set) => (...))` middleware | `useAtomsDevtools('label')` |
| Immer (built-in in RTK) | `immer((set) => (...))` middleware | Not needed — atoms are granular |
| `combineReducers` / slices | Slices pattern or multiple stores | Compose atoms — each atom is independent |
| Middleware chain | `create(devtools(persist(immer(...))))` | Atom utilities: `atomWithStorage`, `atomWithReducer` |
| `extraReducers` (action listeners) | `subscribe(listener)` on store | `onMount` 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
```

## Decision Logic

Agent-facing if/then rules for recommending a migration path. Apply top-down; the first matching rule wins.

### If the project is on React 17 or earlier

--> Do NOT install Zustand v5 (it dropped React <18 in v5.0). Use Zustand 4.x (`npm i zustand@^4`) or choose Jotai 2.x, which still supports React 17+. Plan a React 18 upgrade before adopting Zustand 5.0.14. [src1, src6]

### If the Redux store is small (<5 slices, simple CRUD, no sagas)

--> Migrate to Zustand — it has the closest mental model to Redux and gives near 1:1 slice-to-store mapping. Expect a 1–3 week migration for a mid-sized app; "less code" is the single most-cited reason teams switch in 2026. [src4, src8]

### If the app still ships features daily and cannot freeze

--> Migrate slice-by-slice with both libraries running side-by-side (incremental coexistence). Keep the Redux `<Provider>` until the last slice is gone. Never attempt a big-bang rewrite. [src4, src5]

### If most of the "Redux state" is actually server/API cache

--> Do NOT move it to Zustand or Jotai. Migrate that layer to TanStack Query (React Query) for caching, invalidation, and polling; keep only true client/UI state in Zustand or Jotai. [src3]

### If the state is many small independent values (form fields, toggles, filters) needing fine-grained re-renders

--> Choose Jotai (2.20.0) — atomic bottom-up model with native Suspense support. Define every atom at module scope. Use `jotai-family` (not the deprecated `jotai/utils` `atomFamily`). [src2, src7]

### If you adopt Zustand v5 and a selector returns a new object/array

--> Wrap the selector in `useShallow` (from `zustand/react/shallow`) or use `createWithEqualityFn` with `shallow` — v5 compares with `Object.is` by default, so a fresh reference causes an infinite re-render loop. The plain `create()` no longer accepts an equality-function argument. [src6]

### If you persist state with Zustand's `persist` middleware

--> Pin Zustand `>= 5.0.14`. v5 stopped storing initial state at store-creation time and shipped persist race-condition and rehydration fixes across 5.0.10–5.0.12. Order middleware `create(devtools(persist(immer(...))))` and `partialize` out any tokens/secrets. [src6]

## Step-by-Step Guide

### 1. Audit your Redux usage

Map every Redux slice, thunk, selector, and middleware in your codebase. Identify which slices are "client state" (UI, forms, auth) vs "server cache" (API data). Server cache is better handled by React Query/TanStack Query than by any state library. [src4]

```bash
# 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 your target library without removing Redux. [src1, src3]

```bash
# For Zustand:
npm install zustand
# Optional middleware:
npm install immer  # if you want mutable-style updates

# 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]

```typescript
// BEFORE: Redux Toolkit slice
// store/counterSlice.ts
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
// store/useCounterStore.ts
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' })
);

export default useCounterStore;
```

**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]

```typescript
// BEFORE: Redux slice with { value, history }
// AFTER: Jotai atoms
// store/counterAtoms.ts
import { atom } from 'jotai';

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

// Write atom (equivalent to Redux action)
export const incrementAtom = atom(null, (get, set) => {
  set(countAtom, get(countAtom) + 1);
});

export const decrementAtom = 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 (equivalent to Redux selector)
export const countSummaryAtom = atom((get) => ({
  current: get(countAtom),
  total: get(historyAtom).length,
  last: get(historyAtom).at(-1) ?? null,
}));
```

**Verify**: Replace `useSelector` with `useAtomValue` and `useDispatch` with `useSetAtom` in one component. Confirm identical behavior.

### 5. Migrate async operations (thunks)

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

```typescript
// BEFORE: Redux createAsyncThunk
const fetchUsers = createAsyncThunk('users/fetch', async () => {
  const res = await fetch('/api/users');
  return res.json();
});
// extraReducers: builder.addCase(fetchUsers.pending, ...)

// 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' })
);

// AFTER: Jotai async atom
const usersAtom = atom<User[]>([]);
const loadingAtom = atom(false);
const errorAtom = atom<string | null>(null);

const fetchUsersAtom = atom(null, async (get, set) => {
  set(loadingAtom, true);
  set(errorAtom, null);
  try {
    const res = await fetch('/api/users');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    set(usersAtom, await res.json());
  } catch (err) {
    set(errorAtom, (err as Error).message);
  } finally {
    set(loadingAtom, false);
  }
});
```

**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]

```typescript
// BEFORE: redux-persist
import { persistReducer, persistStore } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
const persistConfig = { key: 'auth', storage, whitelist: ['token', 'user'] };
const persistedReducer = persistReducer(persistConfig, authReducer);

// AFTER: 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',  // localStorage key
      partialize: (state) => ({ token: state.token, user: state.user }),
    }
  )
);

// AFTER: Jotai atomWithStorage
import { atomWithStorage } from 'jotai/utils';

const tokenAtom = atomWithStorage<string | null>('auth-token', null);
const userAtom = atomWithStorage<User | null>('auth-user', null);
```

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

### 7. Handle SSR (Next.js) store isolation

Zustand stores are singletons by default (module-scoped). For SSR with Next.js, use the `createStore` + React context pattern to avoid state leaking between requests. Zustand v5 introduced experimental `unstable_ssrSafe` middleware for this use case. [src6, src8]

```typescript
// 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>
  );
}

export function useAppStore<T>(selector: (s: ReturnType<AppStore['getState']>) => T) {
  const store = useContext(AppStoreContext);
  if (!store) throw new Error('Missing AppStoreProvider');
  return useStore(store, selector);
}
```

**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]

```bash
npm uninstall @reduxjs/toolkit react-redux redux-persist redux-thunk redux-saga
# Remove Provider wrapper from app root
# Delete store/index.ts, slices/, and any Redux-specific files

# Verify no Redux references remain
grep -rn "redux\|createSlice\|useSelector\|useDispatch\|configureStore" --include='*.ts' --include='*.tsx' --include='*.js'
```

**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](scripts/typescript-react-full-redux-to-zustand-migration-t.ts) (77 lines)

```typescript
// Input:  Redux Toolkit todo slice with CRUD + filter
// Output: Equivalent Zustand store with devtools + persist
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
# ... (see full script)
```

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

> Full script: [typescript-react-full-redux-to-jotai-migration-tod.ts](scripts/typescript-react-full-redux-to-jotai-migration-tod.ts) (60 lines)

```typescript
// Input:  Redux Toolkit todo slice with CRUD + filter
// Output: Equivalent Jotai atoms with derived state
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
interface Todo {
# ... (see full script)
```

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

> Full script: [typescript-react-gradual-coexistence-redux-zustand.ts](scripts/typescript-react-gradual-coexistence-redux-zustand.ts) (35 lines)

```typescript
// 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'; // already migrated to Zustand
# ... (see full script)
```

## Anti-Patterns

### Wrong: Wrapping Zustand store in a Provider like Redux

```typescript
// ❌ BAD — Zustand does not need a Provider; this adds unnecessary complexity
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

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

// Component.tsx — just import and use
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)

```typescript
// ❌ 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

```typescript
// ✅ GOOD — Only re-renders when user.name changes
function UserProfile() {
  const name = useUserStore((s) => s.user.name);
  return <span>{name}</span>;
}

// For selecting multiple fields, use useShallow:
import { useShallow } from 'zustand/react/shallow';

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

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

### Correct: Return new state objects, or use Immer middleware

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

// ✅ GOOD (option B) — Immer middleware for mutable-style updates
import { immer } from 'zustand/middleware/immer';

const useStore = create(immer((set) => ({
  todos: [],
  addTodo: (todo) => set((state) => {
    state.todos.push(todo);  // Safe with Immer
  }),
})));
```

### Wrong: Creating atoms inside components (Jotai)

```typescript
// ❌ BAD — New atom on every render, state is lost each time
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

```typescript
// ✅ GOOD — Atom defined once at module scope, stable reference
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

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

### Correct: Use useShallow for object selectors in Zustand v5

```typescript
// ✅ 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

- **Selecting the entire store object**: Calling `useStore()` without a selector causes the component to re-render on every state change, negating Zustand's performance benefits. Fix: Always pass a selector: `useStore(s => s.specificField)`. For multiple fields, use `useShallow`. [src1]
- **Wrong middleware ordering in Zustand**: `immer(devtools(...))` breaks DevTools because `devtools` must see the raw `setState`. Fix: Correct order is `create(devtools(persist(immer(...))))` — devtools outermost, immer innermost. [src6]
- **Migrating everything at once**: Big-bang migrations stall feature development and introduce regressions. Fix: Migrate one slice at a time. Both Redux and Zustand/Jotai run side-by-side during transition. [src4]
- **Forgetting to handle Zustand v5 strict equality**: Zustand v5 uses `Object.is` by default (matching React). Returning a new object from a selector causes infinite re-renders. Fix: Use `useShallow` or `createWithEqualityFn` with `shallow` comparator. [src6]
- **Treating Jotai atoms like Redux slices**: Creating one giant atom with all state defeats Jotai's purpose. Fix: Break state into small, independent atoms. Compose with derived atoms. Each atom should hold one concern. [src2]
- **Not migrating server state to TanStack Query**: Redux often manages both client state and server cache (API data). Zustand/Jotai are for client state. Fix: Move API data fetching to TanStack Query (React Query), keep only UI state in Zustand/Jotai. [src3]
- **Losing Redux DevTools after migration**: Developers forget to add the `devtools` middleware to Zustand stores. Fix: Wrap every store with `devtools(...)` and give each a unique `name` for the Redux DevTools panel. [src3]
- **Mixing Redux `dispatch` patterns into Zustand**: Calling `useStore.setState({ type: 'INCREMENT' })` like a Redux action. Fix: Define actions as functions on the store and call them directly: `useStore(s => s.increment)()`. [src5]
- **Relying on Zustand v4 persist initial-state behavior**: v5 no longer writes the initial state to storage at store creation, and the persist race-condition fix only landed in v5.0.10 (further fixes through 5.0.12). Fix: pin Zustand >= 5.0.14 and set persisted values explicitly after hydration. [src6]

## Diagnostic Commands

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

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

# Check Zustand/Jotai bundle impact
npx bundlephobia zustand     # ~3 KB gzipped
npx bundlephobia jotai        # ~4 KB gzipped
npx bundlephobia @reduxjs/toolkit  # ~14 KB gzipped

# 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

| Library | Current Version | Min React | Key Changes | Notes |
|---|---|---|---|---|
| Zustand 5.x | 5.0.14 (May 2026) | React 18+, TS 4.5+ | Native `useSyncExternalStore`, strict `Object.is` equality, `useShallow` hook, `create` no longer accepts equality fn, persist no longer stores initial state at creation | Use `createWithEqualityFn` if migrating from v4 with `shallow`. Persist race-condition fix in v5.0.10; further persist/devtools fixes through 5.0.12–5.0.14. |
| Zustand 4.x | 4.5.7 | React 16.8+ | Introduced `immer` middleware, `persist` v2 | Upgrade to v5: remove deprecated `create` with equality fn; bump to React 18 first |
| Jotai 2.x | 2.20.0 (May 2026) | React 17+ | Stable v2 API, `atomWithStorage`, `useAtomValue`/`useSetAtom`, `jotai/babel` moved to `jotai-babel` | `atomFamily` deprecated in favor of `jotai-family` package |
| Jotai 1.x | 1.13.1 | React 16.8+ | Initial stable release | Must migrate to v2 API: `useAtom` replaces `useUpdateAtom`/`useAtomValue` from utils |
| Redux Toolkit 2.x | 2.5.0 (Jan 2026) | React 16.8+ | ESM-only, updated `createSlice` types | Source library — this is what you migrate from |

## When to Use / When Not to Use

| Use Zustand When | Use Jotai When | Stay 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 domain | Need fine-grained re-render optimization | Complex middleware chains (sagas, epics) |
| Server-side rendering with Next.js | Using React Suspense for async state | Regulatory/enterprise requirement for Redux |
| Small-medium app, rapid development | TypeScript-heavy project with computed state | Application already stable, no performance issues |
| Team familiar with Flux/Redux concepts | Bottom-up state composition | RTK Query handles all your server state needs |

## Important Caveats

- Zustand v5 dropped support for React <18. If your app uses React 17, use Zustand 4.x or choose Jotai instead.
- Zustand's `persist` middleware stores state in localStorage by default. If your Redux store held sensitive data (tokens), configure `partialize` to exclude secrets or use `sessionStorage`.
- Jotai atoms defined inside components are re-created every render, losing state. Always define atoms at module scope or use `useMemo` with `atom()`.
- Redux Toolkit's Immer is built-in; Zustand requires explicitly adding the `immer` middleware. Without it, `state.arr.push()` silently fails to trigger re-renders.
- Both Zustand and Jotai lack built-in equivalents to RTK Query's cache invalidation, optimistic updates, and polling. Use TanStack Query alongside them for server state.
- Zustand stores are singletons by default (module-scoped). For per-request isolation in SSR (Next.js), use `createStore` + React context pattern or the experimental `unstable_ssrSafe` middleware.
- Jotai v2.17+ deprecated `atomFamily` in `jotai/utils` — use the separate `jotai-family` package instead.
- Zustand v5 changed `persist` behavior: it no longer stores the initial state at store-creation time. If you relied on the v4 behavior, set the value explicitly after hydration. Pin Zustand `>= 5.0.14` — the persist race-condition fix landed in v5.0.10 and further persist/rehydration fixes shipped through 5.0.12.

## Related Units

- [jQuery to React Migration](/software/migrations/jquery-to-react/2026)
- [React Class Components to Hooks](/software/migrations/react-classes-to-hooks/2026)
- [JavaScript to TypeScript Migration](/software/migrations/javascript-to-typescript/2026)
- [Webpack to Vite Migration](/software/migrations/webpack-to-vite/2026)
