leaner state management, no big bang
Redux still solves valid problems today, but in many projects it buries the actual business logic under boilerplate. Whoever migrates from Redux to Zustand step by step replaces reducers, actions and middleware slice by slice, without rewriting the app in a risky single pass.
Table of Contents
- 1. Why teams migrate from Redux to Zustand
- 2. Migration strategy: slice by slice instead of a big bang
- 3. Migrating the first store slice
- 4. Selectors and memoization without Reselect
- 5. Async actions: from thunks to plain functions
- 6. Rebuilding middleware and persistence
- 7. Keeping devtools and time travel debugging
- 8. Running Redux and Zustand side by side
- 9. Redux and Zustand compared directly
- 10. Summary
- 11. FAQ
1. Why teams migrate from Redux to Zustand
Redux was the default answer to global state in React for years, but many teams find that they want to migrate from Redux to Zustand as soon as the boilerplate starts obscuring the actual business logic. A simple counter needs an action, an action creator, a reducer and a store slice entry in classic Redux. In Zustand, the same counter is a single function with an object that combines state and updater functions.
A second reason for the migration is bundle size. Redux Toolkit including React Redux bindings weighs noticeably more than Zustand, which needs no provider, no context and no extra bindings. For projects where every kilobyte in the initial bundle counts, this is a measurable advantage teams quickly notice when comparing the two libraries.
The third reason is the mental entry barrier for new team members. Redux requires understanding reducers, immutability patterns, middleware and often Reselect for selectors as well. Whoever migrates from Redux to Zustand reduces these concepts to a single store object with functions, which noticeably shortens onboarding time for new developers.
2. Migration strategy: slice by slice instead of a big bang
Migrating from Redux to Zustand in a single pull request is practically always the wrong call for medium to large applications. Instead, you migrate slice by slice: a business area like cart or auth gets fully moved to Zustand, while the rest of the app keeps running on Redux. Both state containers coexist in the same tree for the entire transition period.
For ordering, a similar rule applies as with other migrations: slices with few dependencies on other parts of the store first, complex slices with many cross slice selectors last. A slice read only by a single feature component can be migrated in hours. A slice read by ten different components across the app needs considerably more planning and test coverage before being touched.
It is important that the public interface of a migrated slice initially stays identical. Components that previously used useSelector and useDispatch should be moved, as an intermediate step, to a dedicated hook such as useCartStore, which internally addresses either Redux or Zustand. That way the migration stays invisible to calling components.
3. Migrating the first store slice
Porting a Redux slice to Zustand removes the strict separation between reducer and action creator. Zustand defines state and the functions that change it in the same store definition. This often reduces the number of files per business area from three or four down to one.
// BEFORE: Redux Toolkit slice with actions, reducer and selectors
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
addItem: (state, action) => { state.items.push(action.payload); },
removeItem: (state, action) => {
state.items = state.items.filter(i => i.id !== action.payload);
},
clearCart: (state) => { state.items = []; },
},
});
export const { addItem, removeItem, clearCart } = cartSlice.actions;
export const selectCartTotal = (state) =>
state.cart.items.reduce((sum, i) => sum + i.price, 0);
export default cartSlice.reducer;
// AFTER: Zustand store combining state and actions in one place
import { create } from 'zustand';
export const useCartStore = create((set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
clearCart: () => set({ items: [] }),
cartTotal: () => get().items.reduce((sum, i) => sum + i.price, 0),
}));
A common mistake during this migration: developers try to recreate the Redux reducer style with a huge switch statement inside Zustand, instead of using the direct update functions Zustand offers. Whoever migrates from Redux to Zustand should use this opportunity to actually simplify the code, instead of just swapping the syntax while keeping the old mindset.
4. Selectors and memoization without Reselect
In Redux, Reselect is frequently used to memoize expensive derived values and prevent unnecessary re renders. Zustand solves this problem natively through selector functions passed to the useCartStore(selector) call. As long as the selector only reads a primitive field, Zustand's built in reference equality is entirely sufficient, with no extra library needed.
For more complex derived values computed from several fields, a light useMemo in the calling component or a dedicated selector function with useShallow from the Zustand package, which enables shallow object comparisons for selectors returning several fields at once, is still recommended. Whoever migrates from Redux to Zustand and had Reselect selectors can usually keep the pure computation logic unchanged and only swap out the memoization mechanism.
5. Async actions: from thunks to plain functions
Redux thunks wrap asynchronous logic in functions that return a function instead of an action object, receiving dispatch and getState as parameters. Migrating from Redux to Zustand removes this detour entirely, because a Zustand action can simply be a normal async function that calls set right after the await.
// BEFORE: Redux thunk for an async action
export const fetchProducts = () => async (dispatch, getState) => {
dispatch({ type: 'products/loading' });
try {
const products = await api.getProducts();
dispatch({ type: 'products/loaded', payload: products });
} catch (error) {
dispatch({ type: 'products/error', payload: error.message });
}
};
// AFTER: Zustand store with the async logic inlined as a plain function
export const useProductStore = create((set) => ({
products: [],
isLoading: false,
error: null,
fetchProducts: async () => {
set({ isLoading: true, error: null });
try {
const products = await api.getProducts();
set({ products, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
}));
6. Rebuilding middleware and persistence
Custom Redux middleware for logging, analytics or persistence doesn't have to be lost in the migration. Zustand offers its own middleware functions such as persist for LocalStorage persistence and subscribeWithSelector for targeted reactions to state changes outside React components, for example for analytics events. Both can be composed around the store just like Redux middleware.
A team migrating from Redux to Zustand that previously used redux-persist for syncing with LocalStorage replaces it with Zustand's persist middleware, which requires noticeably less configuration and needs no separate rehydration logic in the app. For very specific middleware without a direct Zustand equivalent, a small custom wrapper function of just a few lines around the store definition can almost always be written.
7. Keeping devtools and time travel debugging
One argument raised against migrating from Redux to Zustand is losing the Redux DevTools with time travel debugging. That is no longer true: Zustand offers the same browser extension integration through its devtools middleware, including action names, diff views, and the ability to jump back to an earlier state.
The only difference is that every state change function in Zustand should get a name for the devtools, such as set(newState, false, 'cart/addItem'), so the devtools display stays as descriptive as it was with named Redux actions. Teams migrating from Redux to Zustand should follow this naming convention consistently from the start to avoid losing debugging comfort.
8. Running Redux and Zustand side by side
During the transition, both state containers must be able to coexist without components knowing which one they are currently reading. A pragmatic approach is a facade hook per business area that internally addresses either useSelector or the new Zustand store, depending on whether the slice has already been migrated. A feature flag per slice makes visible which part of the app has already been switched over.
It is important that both systems really stay independent and do not try to synchronize with each other. A Redux slice that reads values from a Zustand store to copy them into its own state creates two sources of truth for the same value, and therefore exactly the bugs a clean migration is supposed to avoid.
9. Redux and Zustand compared directly
The table below summarizes the key differences that actually decide whether migrating from Redux to Zustand makes sense.
| Aspect | Redux Toolkit | Zustand | Impact |
|---|---|---|---|
| Boilerplate per feature | Slice, reducer, selector | One store object | Far fewer files |
| Bundle size | Larger with React Redux | Very small, no provider | Faster initial loads |
| Async logic | Thunks or RTK Query | Normal async functions | Fewer concepts to learn |
| Devtools | Built in natively | Via devtools middleware | Both equally usable |
| Large teams, many slices | Established conventions | Conventions self set up | Redux favors very large teams |
The table shows that migrating from Redux to Zustand is not the right call in every case. Very large teams with many developers benefit from Redux's established, enforced conventions, while smaller teams and projects with strict bundle size limits usually benefit noticeably from Zustand.
Mironsoft
React state management, Redux migrations and architecture consulting
Is Redux boilerplate slowing your team down?
We analyze your Redux store, plan the migration slice by slice, and support the switch to Zustand including middleware, devtools and test coverage.
Store Audit
Slice analysis by dependencies and migration effort
Guided Migration
Redux and Zustand coexistence without regressions
Middleware Handover
Persistence, logging and devtools conventions carried over cleanly
10. Summary
Whoever wants to migrate from Redux to Zustand should never start with a big bang, but proceed slice by slice, beginning with business areas that have few dependencies on other parts of the store. Reducer, action creator and selector get replaced by a single store object combining state and update functions. Thunks give way to normal async functions, while middleware for persistence and devtools survives through Zustand's own extensions.
The biggest win of migrating from Redux to Zustand is rarely raw performance, but the drastically reduced amount of boilerplate and shorter onboarding time for new team members. For very large teams with strict convention requirements, Redux still remains the more robust choice in many cases, which is why the decision to migrate should be made on a project by project basis.
Migrating from Redux to Zustand: The Essentials
Migration order
Slice by slice, starting with business areas without many cross slice dependencies.
Store structure
Reducer, actions and selectors become a single store object with set and get functions.
Middleware
Zustand's persist and devtools middleware cover most Redux middleware use cases.
Coexistence
Facade hooks per slice keep the migration invisible to calling components.