Migrating from Redux to Zustand: Leaner State Management
AI generated
</>
{ }
React · State Management · Redux · Zustand
Migrating from Redux to Zustand
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.

18 min read Store · Selectors · Middleware · Devtools Redux Toolkit · Zustand 5

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.

11. FAQ: Migrating from Redux to Zustand

1Worth it for every project?
Not necessarily, large teams benefit from Redux conventions, smaller teams mostly from Zustand.
2Can both run side by side?
Yes, as long as they don't synchronize and facade hooks hide the migration.
3What happens to thunks?
They become normal async functions calling set right after the await.
4Do I lose devtools?
No, Zustand's devtools middleware offers the same extension integration including time travel.
5Need a Reselect equivalent?
Not for simple selectors, useShallow or useMemo helps for complex ones.
6How do I migrate redux-persist?
Zustand's persist middleware replaces it with less configuration.
7In what order should slices migrate?
Few dependency slices first, tightly coupled ones last.
8Suitable for large teams?
To some extent, custom conventions for store structure should be established.
9Do I need to replace RTK Query?
Not necessarily, can run alongside or be replaced by TanStack Query.
10How do I test the new store?
Directly instantiable as a function without a provider, no need to render React at all.