Skip to main content

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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.