const useStore = create((set) => ({ count: 0, inc: () => set(s => ({ count: s.count + 1 })) }))create(devtools(persist(immer(...)))) — devtools outermost, immer innermost. Wrong order breaks DevTools. [src6]persist middleware uses localStorage by default — configure partialize to exclude sensitive data (tokens, secrets) or use sessionStorage. [src3]| 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) | 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) |
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
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.
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.
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.
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.
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.
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.
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.
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.
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' }
)
);
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;
}
});
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>
);
}
// ❌ 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>
);
}
// ✅ 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>;
}
// ❌ BAD — Every state change re-renders this component
function UserProfile() {
const store = useUserStore(); // selects everything
return <span>{store.user.name}</span>;
}
// ✅ 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>;
}
// ❌ 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;
}),
}));
// ✅ 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); }),
})));
// ❌ 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>;
}
// ✅ 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>;
}
// ❌ 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
}
// ✅ 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>;
}
useStore() without a selector causes re-renders on every state change. Fix: Always pass a selector: useStore(s => s.specificField). For multiple fields, use useShallow. [src1]immer(devtools(...)) breaks DevTools. Fix: Correct order is create(devtools(persist(immer(...)))) — devtools outermost, immer innermost. [src6]Object.is by default. Returning a new object from a selector triggers re-renders. Fix: Use useShallow or createWithEqualityFn with shallow comparator. [src6]devtools middleware to Zustand stores. Fix: Wrap every store with devtools(...) and give each a unique name. [src3]useStore.setState({ type: 'INCREMENT' }) like a Redux action. Fix: Define actions as functions on the store and call them directly. [src5]# 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
| Library | Current Version | Min React | Key Changes | Notes |
|---|---|---|---|---|
| Zustand 5.x | 5.0.11 (Feb 2025) | React 18+ | Strict equality default, useShallow, experimental unstable_ssrSafe | Use createWithEqualityFn if migrating from v4. Persist fix in v5.0.10. |
| Zustand 4.x | 4.5.5 | React 16.8+ | immer middleware, persist v2 | Upgrade to v5: remove deprecated equality fn |
| Jotai 2.x | 2.18.0 (Feb 2025) | React 17+ | atomWithStorage, useAtomValue/useSetAtom, jotai/babel → jotai-babel | atomFamily deprecated, use jotai-family |
| Jotai 1.x | 1.13.1 | React 16.8+ | Initial stable release | Migrate to v2 API |
| Redux Toolkit 2.x | 2.5.0 (Jan 2026) | React 16.8+ | ESM-only, updated types | Source library (migrate from) |
| 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 server state needs |
persist middleware stores state in localStorage by default. If your Redux store held sensitive data (tokens), configure partialize to exclude secrets or use sessionStorage.useMemo with atom().immer middleware. Without it, state.arr.push() silently fails to trigger re-renders.createStore + React context pattern or the experimental unstable_ssrSafe middleware.atomFamily in jotai/utils — use the separate jotai-family package instead.