How to Migrate from Redux to Zustand
·PkgPulse Team
TL;DR
Migrating from Redux to Zustand can be done incrementally — run both stores side-by-side during migration. Zustand's API is far simpler: no actions, no reducers, no selectors boilerplate — just a store function. Most Redux slices convert to Zustand stores in 50% less code. The key shift: Zustand uses closures instead of Redux's action pattern. You can migrate one slice at a time without touching other parts of the app.
Key Takeaways
- 50-70% less boilerplate — Redux slice + actions + selectors → single Zustand store
- Incremental migration — run Redux and Zustand side-by-side during transition
- No Provider required — Zustand stores are just hooks, no
<Provider>wrapping - Simpler async — async actions are just async functions in the store
- Redux DevTools — Zustand supports Redux DevTools via middleware
The Mental Model Shift
// Redux mental model:
// 1. Define action types
// 2. Define action creators
// 3. Define reducers (respond to actions)
// 4. Define selectors (read from state)
// 5. Dispatch actions from components
// 6. Select from store with useSelector
// Zustand mental model:
// 1. Define store with state + actions together
// 2. Call store actions directly from components
// 3. Read state with the hook
// The difference: Redux separates reads and writes
// Zustand combines them in one place
Before: Redux Slice
// store/userSlice.ts — typical Redux Toolkit slice
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
// 1. Types
interface User {
id: string;
name: string;
email: string;
}
interface UserState {
currentUser: User | null;
users: User[];
loading: boolean;
error: string | null;
}
// 2. Async thunk
export const fetchUsers = createAsyncThunk(
'users/fetchAll',
async (_, thunkAPI) => {
const response = await fetch('/api/users');
if (!response.ok) return thunkAPI.rejectWithValue('Failed to fetch');
return response.json() as Promise<User[]>;
}
);
// 3. Slice with reducers
const userSlice = createSlice({
name: 'users',
initialState: { currentUser: null, users: [], loading: false, error: null } as UserState,
reducers: {
setCurrentUser(state, action: PayloadAction<User | null>) {
state.currentUser = action.payload;
},
clearUsers(state) {
state.users = [];
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => { state.loading = true; })
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.users = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
},
});
export const { setCurrentUser, clearUsers } = userSlice.actions;
export default userSlice.reducer;
// 4. Selectors
export const selectCurrentUser = (state: RootState) => state.users.currentUser;
export const selectUsers = (state: RootState) => state.users.users;
export const selectUsersLoading = (state: RootState) => state.users.loading;
// 5. Component usage:
const currentUser = useSelector(selectCurrentUser);
const dispatch = useDispatch();
dispatch(fetchUsers());
dispatch(setCurrentUser(user));
After: Zustand Store
// store/userStore.ts — Zustand equivalent
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface User {
id: string;
name: string;
email: string;
}
interface UserStore {
// State
currentUser: User | null;
users: User[];
loading: boolean;
error: string | null;
// Actions (defined inline — no action creators needed)
setCurrentUser: (user: User | null) => void;
clearUsers: () => void;
fetchUsers: () => Promise<void>;
}
export const useUserStore = create<UserStore>()(
devtools( // Redux DevTools support
(set, get) => ({
// Initial state
currentUser: null,
users: [],
loading: false,
error: null,
// Actions
setCurrentUser: (user) => set({ currentUser: user }),
clearUsers: () => set({ users: [] }),
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
const users = await response.json();
set({ users, loading: false });
} catch (error) {
set({ error: (error as Error).message, loading: false });
}
},
}),
{ name: 'UserStore' } // DevTools display name
)
);
// Component usage — no Provider, no dispatch, no selectors
const currentUser = useUserStore(state => state.currentUser);
const { fetchUsers, setCurrentUser } = useUserStore();
await fetchUsers();
setCurrentUser(user);
Incremental Migration Strategy
// Option A: Run both stores, sync state between them
// Good for large apps that can't be migrated all at once
// Create a bridge that keeps Redux and Zustand in sync
import { store as reduxStore } from '@/store/redux';
import { useUserStore } from '@/store/userStore';
// In a root component or _app.tsx:
function SyncReduxToZustand() {
const { setCurrentUser } = useUserStore();
useEffect(() => {
// Subscribe to Redux changes
const unsubscribe = reduxStore.subscribe(() => {
const reduxUser = selectCurrentUser(reduxStore.getState());
setCurrentUser(reduxUser);
});
return unsubscribe;
}, []);
return null;
}
// Option B: Migrate one slice at a time (preferred)
// 1. Create the Zustand store for the slice
// 2. Update components that use that slice to use Zustand
// 3. Remove the Redux slice
// 4. Repeat for next slice
// Start with the simplest, most isolated slice
// (UI state, preferences, not core data models)
// Work toward complex slices (auth, cart) last
// Phase 1: Migrate UI state (easiest)
// Before:
const { sidebarOpen } = useSelector(selectUI);
dispatch(toggleSidebar());
// After:
const { sidebarOpen, toggleSidebar } = useUIStore();
toggleSidebar();
// Phase 2: Migrate data slices
// Phase 3: Remove Redux entirely
// npm uninstall @reduxjs/toolkit react-redux
Common Migration Patterns
Pattern 1: Selectors → Computed Values
// Redux: separate selector functions
const selectUserById = (id: string) => (state: RootState) =>
state.users.users.find(u => u.id === id);
// Zustand: inline in component (simple)
const user = useUserStore(state => state.users.find(u => u.id === id));
// Zustand: computed getter (for reuse)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useUserStore = create<UserStore & {
getUserById: (id: string) => User | undefined;
}>((set, get) => ({
users: [],
getUserById: (id) => get().users.find(u => u.id === id),
}));
Pattern 2: Redux Middleware → Zustand Middleware
// Redux: logger, persist, thunk middleware
const store = configureStore({
reducer: rootReducer,
middleware: (getDefault) => getDefault().concat(logger),
});
// Zustand: composable middleware
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
const useStore = create<State>()(
devtools( // Redux DevTools
persist( // localStorage persistence
immer( // Immer for mutations (like RTK's produce)
(set) => ({
// ... store definition
})
),
{ name: 'my-store' }
)
)
);
Full Migration Checklist
Phase 1 — Setup
[ ] Install Zustand: npm install zustand
[ ] Create first Zustand store for simplest Redux slice
[ ] Verify DevTools work: devtools() middleware + Redux DevTools extension
Phase 2 — Migrate Slices
[ ] UI/preferences slice (no async, no cross-slice dependencies)
[ ] Feature flags or settings slice
[ ] Authentication slice
[ ] Data slices (users, products, etc.)
[ ] Complex cross-slice state (cart + inventory + user)
Phase 3 — Cleanup
[ ] Remove redux imports from migrated components
[ ] Delete migrated Redux slices
[ ] Remove Redux Provider from root component (when fully migrated)
[ ] Uninstall: npm uninstall @reduxjs/toolkit react-redux
Compare Redux Toolkit and Zustand on PkgPulse.
See the live comparison
View redux toolkit vs. zustand on PkgPulse →