PrimeStack.
Engineering·Feb 1, 2026

Why We Migrated from Redux to Zustand for State Management

A deep dive into our decision to switch state management libraries, the challenges we faced, and the performance gains we achieved.


State management is the part of a React codebase that tends to age the worst. What starts as a sensible, well-structured Redux store at project inception becomes a sprawling collection of slices, selectors, action creators, and middleware that developers dread touching. We hit that wall last year, and after evaluating the alternatives, we migrated from Redux Toolkit to Zustand across a mid-size production application. This is an honest account of why we made the switch, how we did it, and what we learned.


Table of Contents


The Pain Points That Drove the Decision

We were using Redux Toolkit, which is meaningfully better than bare Redux. RTK's createSlice eliminates the action/reducer boilerplate that made classic Redux so tedious. createAsyncThunk handles async flows. createEntityAdapter structures normalized data. The library is well-maintained and well-documented. None of that made it pleasant to work with at scale.

The Boilerplate Problem Never Fully Goes Away

Even with RTK, adding a new piece of state requires navigating a specific ceremony: define the slice, export the actions, export the reducer, register it in the store, write selectors (ideally memoized with createSelector), and connect the component with useSelector and useDispatch. For a simple piece of UI state — a modal's open/closed status — this is genuinely too much overhead. We watched developers skip the store entirely and reach for local useState just to avoid the friction. The result was inconsistent patterns: some state in Redux, some in local component state, with no principled reason for which lived where.

Performance Re-Renders from useSelector

useSelector re-runs on every dispatched action and compares the return value with the previous one. If you are selecting a slice of state that is an object — even if the object's contents did not change — a naive selector will cause a re-render on every dispatch. We had components that were re-rendering on unrelated store updates because their selectors returned new object references. Memoizing with createSelector fixes this, but it adds another dependency to manage, and the memoization logic itself becomes a source of subtle bugs when cache keys are wrong.

DevTools as a Structural Dependency

Redux DevTools is genuinely excellent for debugging. Time-travel debugging, action inspection, and state diff views are useful tools. But we noticed that the way Redux encourages you to architect state — every mutation as a serializable, logged action — started distorting our data model. We were modeling state not around our domain requirements, but around what would look comprehensible in DevTools. The tail wagging the dog.

The Provider Wrapper Cascade

Redux requires a <Provider> wrapping your component tree. In a Next.js App Router application, this means a client component provider at the root, which affects your server/client component boundary decisions. Stores are not automatically scoped to specific subtrees without custom architecture. For a complex app with multiple distinct feature domains, this becomes a meaningful constraint.


Why We Chose Zustand

We evaluated three alternatives: Zustand, Jotai, and Valtio. Jotai's atomic model is elegant but the mental model shift from "one store" to "atoms everywhere" felt like it would introduce different consistency problems for our team. Valtio's proxy-based mutation approach is clever, but we wanted explicit, traceable state updates. Zustand won on simplicity and pragmatism.

Zustand's core characteristics that drove the decision:

Minimal API. The entire API surface is create, useStore, setState, getState, and subscribe. A developer who has read the README for 15 minutes can be productive. There is no dispatch function, no action naming convention, no reducer pattern to follow. You define state and the functions that update it in the same place.

No Provider required. Zustand stores are module-level singletons by default. Components subscribe to them directly with a hook. This works cleanly with Next.js App Router because there is no forced client component provider at the root — individual component files import the store hook directly.

Selective subscription. The useStore(selector) pattern is identical to Redux's useSelector, but Zustand's equality checking is more granular out of the box. You can subscribe to a specific slice of state and the component will only re-render when that slice changes — no manual memoization required for simple cases.

Direct mutation via Immer. Zustand integrates with Immer's produce middleware natively. Combined with the immer middleware, your state updates read as direct mutations while remaining immutable under the hood. This is purely ergonomic, but ergonomics matter for a codebase 10 developers touch daily.


Migration Strategy: Gradual Replacement

We did not attempt a big-bang migration. The application had roughly 18 Redux slices across 6 feature domains. A full rewrite would have been a multi-week freeze on feature development, and the risk of introducing regressions across the entire state surface simultaneously was unacceptable.

Our approach was a three-phase gradual replacement:

Phase 1: Coexistence setup. Redux and Zustand stores operate independently — there is no shared state mechanism that requires coordination. We added Zustand as a dependency alongside Redux and wrote a team convention: new features use Zustand; existing features stay in Redux until explicitly migrated.

Phase 2: Feature-by-feature migration. We prioritized slices by pain level. The authentication slice, which had complex async flows and the most cross-cutting useSelector calls, was migrated first — both to validate our patterns and to eliminate the most frustrating source of re-render debugging. Each slice migration was a standalone PR: new Zustand store, updated component hooks, deleted Redux slice and tests.

Phase 3: Redux removal. Once all slices were migrated, we removed Redux Toolkit, the provider, and the store configuration. This was a single PR with no logic changes — pure cleanup.

The coexistence period lasted approximately 6 weeks. During that time, both stores ran simultaneously without conflict. New developers joining the team during that period found it confusing to have two patterns coexisting, so we documented a clear migration map in the project README.


Code Comparison: The Same Feature in Both Libraries

To make the difference concrete, here is the same feature — a notifications system with async fetch and dismiss — implemented in both RTK and Zustand.

Redux Toolkit Implementation

// store/notificationsSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";

export const fetchNotifications = createAsyncThunk(
  "notifications/fetch",
  async (userId: string) => {
    const res = await fetch(`/api/notifications?userId=${userId}`);
    return res.json();
  }
);

const notificationsSlice = createSlice({
  name: "notifications",
  initialState: { items: [], loading: false, error: null },
  reducers: {
    dismiss(state, action: PayloadAction<string>) {
      state.items = state.items.filter((n) => n.id !== action.payload);
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchNotifications.pending, (state) => {
        state.loading = true;
      })
      .addCase(fetchNotifications.fulfilled, (state, action) => {
        state.loading = false;
        state.items = action.payload;
      })
      .addCase(fetchNotifications.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  },
});

export const { dismiss } = notificationsSlice.actions;
export default notificationsSlice.reducer;

// Selectors
import { createSelector } from "@reduxjs/toolkit";
const selectNotificationsState = (state) => state.notifications;
export const selectNotifications = createSelector(
  selectNotificationsState,
  (s) => s.items
);
export const selectNotificationsLoading = createSelector(
  selectNotificationsState,
  (s) => s.loading
);

// Component usage
import { useDispatch, useSelector } from "react-redux";
const dispatch = useDispatch();
const notifications = useSelector(selectNotifications);
const loading = useSelector(selectNotificationsLoading);
dispatch(fetchNotifications(userId));
dispatch(dismiss(notificationId));

Zustand Implementation

// stores/notificationsStore.ts
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";

interface Notification { id: string; message: string; }

interface NotificationsStore {
  items: Notification[];
  loading: boolean;
  error: string | null;
  fetch: (userId: string) => Promise<void>;
  dismiss: (id: string) => void;
}

export const useNotificationsStore = create<NotificationsStore>()(
  immer((set) => ({
    items: [],
    loading: false,
    error: null,
    fetch: async (userId) => {
      set((state) => { state.loading = true; });
      try {
        const res = await fetch(`/api/notifications?userId=${userId}`);
        const items = await res.json();
        set((state) => { state.items = items; state.loading = false; });
      } catch (err) {
        set((state) => { state.error = err.message; state.loading = false; });
      }
    },
    dismiss: (id) => {
      set((state) => {
        state.items = state.items.filter((n) => n.id !== id);
      });
    },
  }))
);

// Component usage
const { items, loading, fetch, dismiss } = useNotificationsStore();

The Zustand version is 40% fewer lines of code. More importantly, the logic, state shape, and update functions are all colocated in one file. There is no action naming convention to maintain, no selector boilerplate, and no dispatch ceremony. A developer reading this code for the first time understands exactly what the store contains and how it changes.


Performance Results

After completing the migration, we measured two dimensions: bundle size and render counts.

Bundle size. Redux Toolkit ships at approximately 14KB gzipped. Zustand is approximately 1.1KB gzipped. For our application, removing RTK and react-redux reduced our JavaScript bundle by ~18KB. At the 75th percentile on mobile, this translated to a measurable LCP improvement — roughly 120ms — on pages with heavy state usage.

Render counts. We instrumented 12 high-traffic components with React's Profiler API before and after migration. On average, components experienced 34% fewer re-renders under Zustand. The biggest improvements were in list-rendering components that had been triggering on unrelated Redux dispatches. Three components that had been logging 8+ unnecessary renders per user interaction were reduced to 1.

These are real numbers from our application with our data patterns. Your results will vary. The render improvement is partly Zustand and partly the fact that migration forced us to write more deliberate subscriptions — we evaluated what each component actually needed as we migrated each selector.


Pitfalls We Encountered

Derived State Patterns

Redux's createSelector with Reselect gives you memoized derived state with cache invalidation built in. Zustand has no direct equivalent. For simple derived values, a plain selector function works fine:

const expiredItems = useNotificationsStore(
  (state) => state.items.filter((n) => n.expired)
);

But this selector creates a new array reference on every render, causing re-renders in child components that receive it as a prop. The solution is to use useMemo at the component level for expensive derived computations, or adopt the zustand/middleware subscribeWithSelector combined with external memoization libraries like proxy-memoize. It is more manual than Reselect, and for stores with complex derived data, this overhead is real.

Middleware Equivalents

Redux middleware — particularly redux-logger and custom action interceptors — has no direct equivalent in Zustand. We used redux-logger for development debugging, which we replaced with Zustand's devtools middleware (which integrates with Redux DevTools, somewhat ironically). For custom middleware behavior (logging specific state changes), we used Zustand's subscribe API to attach side effects to specific store slices.

Store Scope in Next.js

Zustand's module-level singleton pattern works well in client-side React but creates problems in Next.js server rendering: a singleton store persists across requests on the server, meaning Request A's state can bleed into Request B. The fix is to use Zustand's factory pattern (createStore returning a store instance, not a hook) combined with React context to scope stores per request. This is documented in Zustand's Next.js guide, but it adds back some of the provider complexity we were happy to leave behind.


When Redux Is Still the Right Choice

After a successful migration, it would be easy to declare Redux obsolete. That is not our conclusion.

Large teams with diverse experience levels. Redux's prescriptive structure — everything as a serializable action, reducer as a pure function — is a form of enforced discipline. On a 20-person team where not everyone has strong React patterns, that structure prevents a category of architecture mistakes. Zustand's flexibility is also freedom to make inconsistent choices.

Complex action logging and audit trails. If your application has compliance requirements around state changes — a financial application logging every data mutation, a healthcare app with change tracking requirements — Redux's action log is a natural fit. Every state change is a named, serializable event. Reconstructing that from Zustand requires custom instrumentation.

Time-travel debugging workflows. If your development process relies heavily on Redux DevTools' time-travel replay (not just state inspection), that capability is genuinely harder to replicate in Zustand. The devtools middleware gives you state snapshots, but true action-by-action replay is a Redux-native feature.

Already-working Redux codebases. Migrating a working system carries risk and cost. If your Redux code is well-organized, your team is productive with it, and you are not experiencing the pain points we described, there is no compelling reason to migrate. Zustand is not objectively superior — it is a better fit for our specific context and application size. Evaluate against your constraints, not against ours.

The honest summary: Zustand's ergonomics are better for small-to-medium applications where a small team wants minimal ceremony and strong performance by default. Redux's structure is an asset for large teams, complex audit requirements, and codebases where consistency across many contributors matters more than conciseness. Both tools are actively maintained and production-proven. The right answer depends on what you are actually building.