Skip to main content

How to Migrate from Redux to Zustand 2026

·PkgPulse Team
0

Why Teams Are Moving Away from Redux

Redux was the right tool for 2016. React's component model was young, prop drilling was painful, and applications needed a principled way to share state across component trees. Redux answered that with a strict unidirectional data flow, a centralized store, and the action/reducer pattern that made state changes predictable and traceable.

The problem is that Redux Toolkit — for all the improvements it made over vanilla Redux — is still essentially Redux. You still write slices, define action creators, write extraReducers for async thunks, and dispatch actions from components through useDispatch. For applications where state is relatively simple, this architecture feels like using a freight elevator when you needed a stepladder.

Zustand's growth tells the story. It went from under 1 million weekly downloads in 2022 to over 10 million in 2026, largely because React developers discovered they could replace entire Redux slices with 20-line Zustand stores. No actions. No reducers. No selectors. No Provider. State and the functions that change it live in the same place, and components access them with a single hook.

The migration also tends to happen in one direction. Developers who try Zustand for a small slice rarely go back to Redux for the next feature. The cognitive overhead difference is real, and once your team experiences writing a feature without createSlice and createAsyncThunk, the return to Redux boilerplate feels genuinely regressive.

The bundle size difference compounds the argument. Redux Toolkit adds approximately 10-12KB to your bundle (gzipped), plus react-redux at another 5-6KB. Zustand is 1.8KB. For applications where frontend performance matters, this 14KB reduction — roughly the size of a Day.js library — is a concrete improvement that benefits every user who loads the page.

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

The most important conceptual shift in migrating from Redux to Zustand is that actions and state stop being separate concerns. In Redux, the data flow is: component dispatches an action → reducer handles the action → state updates → components re-render via selectors. This separation gives you a complete audit trail of every state change, which is valuable for debugging complex interactions, but adds ceremony for simple operations.

In Zustand, the store is a single object with both state properties and the functions that modify them. A function to set the current user is defined right next to the current user field, and called directly from the component without going through dispatch. The result is significantly less indirection. When something goes wrong, you trace directly to the function that called the setter, rather than following the dispatch → action → reducer → selector chain.

The Redux DevTools integration is preserved via Zustand's devtools middleware. Time-travel debugging still works. The difference is that you opt into it, and it's one middleware wrapper around your store definition rather than a fundamental requirement of the architecture.

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

Performance: Subscriptions and Re-renders

Zustand's performance model is different from Redux's, and understanding the difference helps avoid a common migration pitfall. In Redux, components use useSelector to subscribe to specific pieces of state, and only re-render when the selected value changes. This is important for performance in large trees.

In Zustand, the equivalent pattern is passing a selector function to the store hook: useUserStore(state => state.currentUser) subscribes only to changes in currentUser, not the entire store. Without the selector, useUserStore() returns the entire store state and re-renders the component on any store change — which is fine for simple stores but can cause unnecessary re-renders in larger applications.

A common mistake during migration is to write const store = useUserStore() and then access store.currentUser, store.users, and store.loading in the same component. This subscribes to the entire store. The fix is either a selector or splitting into separate hooks: useUserStore(state => state.currentUser) for the user, useUserStore(state => state.loading) for loading state. This matches the granularity you had with Redux's useSelector.

For computed values that depend on multiple state fields, Zustand's subscribeWithSelector middleware provides a way to implement memoized selectors similar to Reselect, though in practice a simple inline selector with shallow equality comparison covers most use cases. The useShallow helper (available in Zustand 4.4+) implements shallow equality comparison for object or array selectors, preventing re-renders when the reference changes but the values are the same — a direct equivalent of Redux's shallowEqual second argument to useSelector.

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

Async State: Thunks vs. Zustand Async Functions

Redux's async story evolved from manual dispatch(fetchPending()) calls to createAsyncThunk, which generates the pending/fulfilled/rejected action types automatically and handles the loading/error state pattern in extraReducers. It's functional, but even with Redux Toolkit, an async operation touches five different places in the codebase: the thunk definition, the extraReducers handler, the slice's initial state, the selector, and the component.

Zustand's async approach is simpler by elimination: an async function is just a method on the store that calls set() when it's done. There's no action to dispatch, no extra reducer to write, no separate pending/fulfilled pattern. The async function sets loading: true at the start, calls the API, and then sets the result (or error) when it finishes. The entire pattern fits in the store definition where anyone reading the store can see both the state shape and the operations that change it.

One real advantage of createAsyncThunk is automatic request deduplication and cancellation via AbortController support — if the component unmounts mid-request, the thunk can abort the fetch. Zustand doesn't provide this automatically. For data fetching specifically, TanStack Query is the better replacement for RTK Query and createAsyncThunk, because it handles deduplication, cancellation, background refetching, and cache management as first-class features. Zustand is best reserved for client-side UI state that doesn't have a server-side source of truth.

Zustand Stores: Splitting vs. Single Store

One architectural decision to make early in the migration: should you have a single Zustand store (like Redux's single store) or multiple smaller stores?

The Zustand documentation and community have converged on multiple small stores as the recommended pattern. A useUserStore for user and auth state, a useUIStore for sidebar, theme, and modal state, a useCartStore for e-commerce state — each independently subscribable and independently testable. This approach also makes the incremental migration path cleaner, because you can add Zustand stores one feature at a time without touching existing Redux state.

The single-store pattern is still valid for very simple applications or for teams that want the mental model of Redux's single source of truth. Zustand supports it fine — you can create one large store with create<RootState>(). But the performance characteristics of multiple small stores are generally better because components subscribe to exactly the slice they need, without any risk of over-subscribing to a large global state object.

Store splitting also improves code organization. A Zustand store file reads like a module: it exports a hook, the hook provides everything that feature needs, and the file is a self-contained unit. This is closer to how most React teams organize code today (feature directories, colocated state) than Redux's global reducers directory.

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

When Redux Still Makes Sense

For all its ceremony, Redux has legitimate strengths that Zustand doesn't fully match in every scenario. If your team uses Redux DevTools heavily for time-travel debugging in development — not just as an inspection tool, but for actually rewinding and replaying state sequences during debugging — that workflow is more refined in Redux. Zustand's DevTools support is solid but the time-travel UX is less polished.

Large organizations with multiple teams contributing to a shared state layer sometimes prefer Redux's strict conventions precisely because they're enforced by the architecture. When a Zustand store can be modified however a developer chooses, maintaining consistency across a large team requires code review discipline that Redux's action pattern provides structurally.

If you're using RTK Query (Redux Toolkit's data fetching layer) and it's serving your needs, the migration calculation changes. RTK Query handles server state, caching, and background sync in a way that Zustand doesn't — Zustand is a client state library, not a server state cache. The Zustand equivalent of RTK Query is TanStack Query (or SWR), not Zustand alone. Migrating off RTK Query to Zustand + TanStack Query is a larger project than it initially appears.

The bottom line is that Zustand wins for most client-side UI state management in 2026: it's simpler, faster to write, and requires dramatically less boilerplate. But Redux's strictness and ecosystem depth are real advantages in specific enterprise contexts. Most mid-sized applications benefit from migrating; the largest and most convention-dependent might not. The incremental migration path described in this guide means you don't have to answer the question all at once — try one slice, measure the experience, and decide from there.

Testing Zustand Stores

Testing Zustand stores is significantly simpler than testing Redux. A Redux action flow requires setting up the Redux store with all its reducers and middleware, dispatching actions through the store, and asserting on the resulting state — it's an integration test by necessity. Zustand stores can be tested as pure functions.

The standard approach is to call useUserStore.getState() to read the current store state, and call actions directly on the returned state object. In a test:

import { useUserStore } from '@/store/userStore';

// Reset store state between tests
beforeEach(() => {
  useUserStore.setState({ currentUser: null, users: [], loading: false, error: null });
});

test('setCurrentUser updates currentUser', () => {
  const user = { id: '1', name: 'Alice', email: 'alice@example.com' };
  useUserStore.getState().setCurrentUser(user);
  expect(useUserStore.getState().currentUser).toEqual(user);
});

test('clearUsers empties the users array', () => {
  useUserStore.setState({ users: [{ id: '1', name: 'Alice', email: 'a@a.com' }] });
  useUserStore.getState().clearUsers();
  expect(useUserStore.getState().users).toHaveLength(0);
});

The setState override for test setup and the getState() access for assertions mean that Zustand tests read directly without the mock-store middleware setup that Redux tests require. For testing components that use Zustand stores, the store can be pre-seeded with setState before rendering, no mock Provider required.

For async actions, wrap the call in act() and await it, then assert on the resulting state. This is the same pattern as testing any async React state update.

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 also: Redux vs Zustand and Recoil vs Zustand, Zustand vs Redux Toolkit in 2026: Full Decision Guide.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.