Skip to main content
Stripe SystemsStripe Systems
Frontend Development📅 January 26, 2026· 21 min read

State Management Showdown: Zustand vs Redux Toolkit vs Jotai for Large React Codebases

✍️
Stripe Systems Engineering

React 19 shipped server components, and with them came a reasonable question: do we still need client-side state management libraries? The answer is yes, but the reasoning has shifted. Server components handle data that lives on the server — database records, CMS content, session data. But client-side state hasn't gone anywhere. Form input state, modal visibility, active tab indices, filter selections, optimistic updates before server confirmation, real-time data arriving over WebSockets, user preferences like theme and locale, shopping cart contents during a session — all of this remains firmly on the client.

The question is not whether you need client state management. The question is which tool fits the patterns your application actually uses.

This post compares three libraries — Redux Toolkit, Zustand, and Jotai — in depth. Not surface-level API overviews, but the kind of analysis that matters when you're choosing a tool for a codebase that will grow to hundreds of components and dozens of contributors.

Redux Toolkit Deep Dive

Redux Toolkit (RTK) is the official, opinionated toolset for Redux. It wraps Redux's core concepts — actions, reducers, store — with sensible defaults and less boilerplate than hand-written Redux.

createSlice: Reducers and Actions

createSlice generates action creators and action types automatically from reducer functions. It uses Immer under the hood, so you can write "mutative" logic that produces correct immutable updates.

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface Product {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: Product[];
  totalAmount: number;
  isCheckingOut: boolean;
}

const initialState: CartState = {
  items: [],
  totalAmount: 0,
  isCheckingOut: false,
};

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addItem(state, action: PayloadAction<Product>) {
      const existing = state.items.find(i => i.id === action.payload.id);
      if (existing) {
        existing.quantity += 1;
      } else {
        state.items.push({ ...action.payload, quantity: 1 });
      }
      state.totalAmount = state.items.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      );
    },
    removeItem(state, action: PayloadAction<string>) {
      state.items = state.items.filter(i => i.id !== action.payload);
      state.totalAmount = state.items.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      );
    },
    setCheckingOut(state, action: PayloadAction<boolean>) {
      state.isCheckingOut = action.payload;
    },
  },
});

export const { addItem, removeItem, setCheckingOut } = cartSlice.actions;
export default cartSlice.reducer;

This replaces the old pattern of separate ACTION_TYPES constants, action creator functions, and switch-statement reducers. The Immer integration means state.items.push(...) doesn't actually mutate state — Immer intercepts the operation and produces a new immutable object.

createAsyncThunk: Async Operations

createAsyncThunk handles the lifecycle of async operations — pending, fulfilled, and rejected — without manual action type definitions.

import { createAsyncThunk } from '@reduxjs/toolkit';

export const submitOrder = createAsyncThunk(
  'cart/submitOrder',
  async (orderId: string, { getState, rejectWithValue }) => {
    const state = getState() as RootState;
    try {
      const response = await fetch('/api/orders', {
        method: 'POST',
        body: JSON.stringify({
          orderId,
          items: state.cart.items,
        }),
      });
      if (!response.ok) {
        const error = await response.json();
        return rejectWithValue(error);
      }
      return await response.json();
    } catch (err) {
      return rejectWithValue({ message: 'Network error' });
    }
  }
);

// In the slice, handle all three states:
const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: { /* ... */ },
  extraReducers: (builder) => {
    builder
      .addCase(submitOrder.pending, (state) => {
        state.isCheckingOut = true;
      })
      .addCase(submitOrder.fulfilled, (state) => {
        state.items = [];
        state.totalAmount = 0;
        state.isCheckingOut = false;
      })
      .addCase(submitOrder.rejected, (state, action) => {
        state.isCheckingOut = false;
        state.error = action.payload as string;
      });
  },
});

RTK Query: Data Fetching and Caching

RTK Query is Redux Toolkit's answer to data fetching. It auto-generates hooks, manages caching, handles deduplication, and supports cache invalidation through a tag system.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
}

export const productApi = createApi({
  reducerPath: 'productApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Product', 'ProductList'],
  endpoints: (builder) => ({
    getProducts: builder.query<Product[], { category?: string }>({
      query: (params) => ({
        url: '/products',
        params,
      }),
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Product' as const, id })),
              { type: 'ProductList' },
            ]
          : [{ type: 'ProductList' }],
    }),
    updateProduct: builder.mutation<Product, Partial<Product> & { id: string }>({
      query: ({ id, ...patch }) => ({
        url: `/products/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Product', id }],
    }),
  }),
});

export const { useGetProductsQuery, useUpdateProductMutation } = productApi;

The tag system is what distinguishes RTK Query's caching from simpler approaches. When updateProduct fires, it invalidates the specific Product tag matching that ID, causing any component using useGetProductsQuery to refetch — but only if its cached data included that product. Compare this to React Query, which uses query keys for invalidation. React Query is more flexible for server-state-only scenarios but doesn't integrate with a client-side Redux store. If you already have Redux for client state, RTK Query avoids adding a second state management system.

Custom Middleware

Redux middleware intercepts every action before it reaches the reducer. This is useful for cross-cutting concerns.

import { Middleware } from '@reduxjs/toolkit';

const analyticsMiddleware: Middleware = (store) => (next) => (action) => {
  if (action.type === 'cart/addItem') {
    analytics.track('item_added', {
      productId: action.payload.id,
      cartSize: store.getState().cart.items.length,
    });
  }
  if (action.type === 'cart/submitOrder/fulfilled') {
    analytics.track('order_completed', {
      total: store.getState().cart.totalAmount,
    });
  }
  return next(action);
};

When the Ceremony Pays Off

Redux Toolkit involves more structural ceremony than Zustand or Jotai. You need a store configuration, provider at the root, slices, and if using RTK Query, API definitions. This overhead is justified when you have large teams (10+ developers) where standardized patterns make code reviews predictable, complex state dependencies that benefit from centralized action dispatch, strict audit requirements where action logs serve as an event trail, or workflows requiring time-travel debugging during development. Bundle size is approximately 11KB gzipped for RTK plus 2KB for React-Redux.

Zustand Deep Dive

Zustand takes a minimalist approach. No providers, no context, no boilerplate ritual. A store is a hook, and you use it directly.

Store Creation

import { create } from 'zustand';

interface CartStore {
  items: Product[];
  totalAmount: number;
  addItem: (product: Product) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
}

const useCartStore = create<CartStore>((set, get) => ({
  items: [],
  totalAmount: 0,
  addItem: (product) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === product.id);
      const updatedItems = existing
        ? state.items.map((i) =>
            i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i
          )
        : [...state.items, { ...product, quantity: 1 }];
      return {
        items: updatedItems,
        totalAmount: updatedItems.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        ),
      };
    }),
  removeItem: (id) =>
    set((state) => {
      const updatedItems = state.items.filter((i) => i.id !== id);
      return {
        items: updatedItems,
        totalAmount: updatedItems.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        ),
      };
    }),
  clearCart: () => set({ items: [], totalAmount: 0 }),
}));

No Provider wrapping your app. No configureStore. You import the hook and call it. The store exists as a module-level singleton.

Selectors and Re-render Control

The most important Zustand concept for performance is selectors. Without a selector, calling useCartStore() subscribes the component to every state change. With a selector, it subscribes only to the slice it actually reads.

// Bad: re-renders on ANY state change
const Component = () => {
  const store = useCartStore();
  return <span>{store.totalAmount}</span>;
};

// Good: re-renders only when totalAmount changes
const Component = () => {
  const totalAmount = useCartStore((state) => state.totalAmount);
  return <span>{totalAmount}</span>;
};

For selecting multiple values, use useShallow to avoid re-renders when the selected values haven't actually changed:

import { useShallow } from 'zustand/react/shallow';

const CartSummary = () => {
  const { items, totalAmount } = useCartStore(
    useShallow((state) => ({
      items: state.items,
      totalAmount: state.totalAmount,
    }))
  );
  return (
    <div>
      {items.length} items — ${totalAmount}
    </div>
  );
};

You can also provide a custom equality function as the second argument to the hook:

const items = useCartStore(
  (state) => state.items,
  (prev, next) => prev.length === next.length
);

Subscriptions Outside React

Zustand stores are not tied to React. You can subscribe to changes from anywhere — useful for logging, analytics, or syncing with external systems.

const unsubscribe = useCartStore.subscribe(
  (state) => state.items,
  (items, previousItems) => {
    if (items.length > previousItems.length) {
      analytics.track('item_added_to_cart', { count: items.length });
    }
  }
);

Middleware Composition

Zustand supports middleware by wrapping the store creator. Multiple middleware can be composed together.

import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

const useCartStore = create<CartStore>()(
  devtools(
    persist(
      subscribeWithSelector(
        immer((set) => ({
          items: [],
          totalAmount: 0,
          addItem: (product: Product) =>
            set((state) => {
              const existing = state.items.find((i) => i.id === product.id);
              if (existing) {
                existing.quantity += 1;
              } else {
                state.items.push({ ...product, quantity: 1 });
              }
              state.totalAmount = state.items.reduce(
                (sum, item) => sum + item.price * item.quantity,
                0
              );
            }),
          removeItem: (id: string) =>
            set((state) => {
              state.items = state.items.filter((i) => i.id !== id);
              state.totalAmount = state.items.reduce(
                (sum, item) => sum + item.price * item.quantity,
                0
              );
            }),
          clearCart: () => set({ items: [], totalAmount: 0 }),
        }))
      ),
      { name: 'cart-storage' }
    ),
    { name: 'CartStore' }
  )
);

The persist middleware serializes state to localStorage (or any custom storage adapter — AsyncStorage for React Native, IndexedDB for large datasets). The immer middleware enables the same mutative syntax Redux Toolkit uses. subscribeWithSelector enables the selector-based subscribe API shown earlier. devtools connects to the Redux DevTools browser extension.

Slices Pattern

For large stores, Zustand supports splitting state into composable slices:

interface CartSlice {
  items: Product[];
  addItem: (product: Product) => void;
}

interface UISlice {
  isSidebarOpen: boolean;
  toggleSidebar: () => void;
}

const createCartSlice = (set: any, get: any): CartSlice => ({
  items: [],
  addItem: (product) =>
    set((state: any) => ({ items: [...state.items, product] })),
});

const createUISlice = (set: any): UISlice => ({
  isSidebarOpen: false,
  toggleSidebar: () =>
    set((state: any) => ({ isSidebarOpen: !state.isSidebarOpen })),
});

const useAppStore = create<CartSlice & UISlice>()((...args) => ({
  ...createCartSlice(...args),
  ...createUISlice(...args),
}));

Each slice is defined as a plain function that receives set and get. Slices can reference each other through get(), enabling cross-slice derived state without tight coupling.

When Zustand Shines

Zustand's strength is its ratio of capability to complexity. At approximately 1KB gzipped, it adds almost nothing to your bundle. It requires no provider hierarchy, no boilerplate ceremony, and no mental model beyond "a hook that returns state and actions." For small-to-medium applications, or for specific feature modules in a larger application, this simplicity pays dividends in developer velocity and onboarding time.

Jotai Deep Dive

Jotai takes a fundamentally different approach to state management. Where Redux and Zustand are store-based (top-down: you define a store, components read from it), Jotai is atom-based (bottom-up: you define independent pieces of state, components compose them).

Primitive Atoms

A primitive atom holds a single value. It's the Jotai equivalent of useState, but the state is shared across components.

import { atom, useAtom } from 'jotai';

const itemsAtom = atom<Product[]>([]);
const filterAtom = atom<string>('all');
const sortOrderAtom = atom<'asc' | 'desc'>('asc');

Derived Atoms (Read-Only)

Derived atoms compute values from other atoms. Jotai tracks which atoms a derived atom depends on and only recalculates when those specific dependencies change.

const filteredItemsAtom = atom((get) => {
  const items = get(itemsAtom);
  const filter = get(filterAtom);
  if (filter === 'all') return items;
  return items.filter((item) => item.category === filter);
});

const sortedItemsAtom = atom((get) => {
  const items = get(filteredItemsAtom);
  const order = get(sortOrderAtom);
  return [...items].sort((a, b) =>
    order === 'asc' ? a.price - b.price : b.price - a.price
  );
});

const totalAtom = atom((get) => {
  const items = get(filteredItemsAtom);
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});

This creates a dependency graph: sortedItemsAtom depends on filteredItemsAtom and sortOrderAtom, while filteredItemsAtom depends on itemsAtom and filterAtom. Changing sortOrderAtom recalculates sortedItemsAtom but does not recalculate filteredItemsAtom — its dependencies didn't change.

Writable Derived Atoms

A writable derived atom defines both a read function and a write function. This is how you create computed setters.

const cartTotalWithTaxAtom = atom(
  (get) => {
    const items = get(itemsAtom);
    const subtotal = items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    return subtotal * 1.08; // 8% tax
  },
  (get, set, newTotal: number) => {
    // When someone sets the total directly, adjust item quantities proportionally
    const items = get(itemsAtom);
    const currentTotal = items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    const ratio = newTotal / (currentTotal * 1.08);
    set(
      itemsAtom,
      items.map((item) => ({
        ...item,
        quantity: Math.round(item.quantity * ratio),
      }))
    );
  }
);

Atom-in-Atom Pattern

Atoms can hold references to other atoms. This enables dynamic state creation — useful when the shape of your state isn't known at build time.

const productAtomFamily = (id: string) => atom<Product | null>(null);

const productAtomsAtom = atom<Map<string, ReturnType<typeof atom>>>(new Map());

// Or use jotai/utils atomFamily:
import { atomFamily } from 'jotai/utils';

const productAtom = atomFamily((id: string) =>
  atom<Product | null>(null)
);

// Each product ID gets its own atom instance
// Updating product "abc" only re-renders components reading product "abc"

Utility Atoms

Jotai provides utility atoms for common patterns:

import { atomWithStorage } from 'jotai/utils';
import { atomWithQuery } from 'jotai-tanstack-query';

// Persisted to localStorage automatically
const themeAtom = atomWithStorage('theme', 'light');

// Integrated with TanStack Query for server state
const productsQueryAtom = atomWithQuery(() => ({
  queryKey: ['products'],
  queryFn: async () => {
    const res = await fetch('/api/products');
    return res.json();
  },
}));

Provider-less Mode vs Provider Scoping

Jotai works without a Provider — atoms use a default global store. But wrapping a subtree with <Provider> creates an isolated scope where atoms have independent values. This is useful for testing and for mounting multiple instances of the same widget with independent state.

import { Provider } from 'jotai';

// Two independent product configurators on the same page
const ProductPage = () => (
  <>
    <Provider>
      <ProductConfigurator productId="abc" />
    </Provider>
    <Provider>
      <ProductConfigurator productId="xyz" />
    </Provider>
  </>
);

When Jotai Shines

Jotai excels when state dependencies form a graph rather than a tree. If your application has many pieces of derived state that depend on combinations of other state, Jotai's atom model expresses this naturally. The mental model is close to a spreadsheet: cells (atoms) reference other cells, and changes propagate through the dependency graph automatically. At approximately 2KB gzipped, Jotai is lightweight, and its React-centric design means atoms behave like a shared, composable useState.

Performance Comparison

Performance differences between these libraries emerge under specific conditions. For most applications, all three are fast enough. The differences matter when you have large component trees, frequent updates, or both.

Re-render Behavior

Consider a product list with 500 items. A user changes a filter, updating the visible set.

Redux Toolkit: Every component calling useSelector re-evaluates its selector function on every dispatched action. If the selector returns a new reference (common with .filter() or .map()), the component re-renders. Without shallowEqual or memoized selectors via createSelector from Reselect, you easily trigger unnecessary re-renders.

// Without memoization — re-creates array reference every time any Redux state changes
const items = useSelector((state: RootState) =>
  state.products.items.filter((i) => i.category === state.filters.category)
);

// With memoization — only recalculates when products or category actually change
const selectFilteredItems = createSelector(
  [(state: RootState) => state.products.items,
   (state: RootState) => state.filters.category],
  (items, category) => items.filter((i) => i.category === category)
);
const items = useSelector(selectFilteredItems);

Zustand: Selectors are the primary API. If you select state.totalAmount, you only re-render when totalAmount changes. But selecting derived values (like a filtered list) has the same reference equality issue as Redux. You handle it the same way — memoize or use useShallow.

Jotai: Atom-level subscriptions provide the finest granularity. If you have a filteredItemsAtom, only components reading that atom re-render when its value changes. And because Jotai tracks the dependency graph, it knows that filteredItemsAtom only needs recalculation when itemsAtom or filterAtom changes — not when unrelated atoms update.

Re-render Comparison

ScenarioRedux ToolkitZustandJotai
Single item update in list of 500All items re-render (without memoization), 1 item (with React.memo + stable keys)Only subscribers to that item's dataOnly components reading that item's atom
Filter changeAll useSelector consumers re-evaluate; re-render depends on selector memoizationComponents selecting the filtered result re-renderComponents reading filteredItemsAtom re-render
Unrelated state change (e.g., theme toggle)All useSelector consumers re-evaluate selector (but skip render if result unchanged)Only components selecting theme stateOnly components reading themeAtom
Adding item to cart while viewing product listProduct list components re-evaluate selectorsNo re-render if product list doesn't select cart stateNo re-render — cart atoms are independent

The practical takeaway: Jotai provides the finest default granularity, Zustand provides good granularity when you use selectors correctly, and Redux requires the most deliberate optimization through memoized selectors and React.memo.

DevTools and Debugging

Redux DevTools

Redux DevTools is the gold standard for state debugging. It provides time-travel debugging (step backward and forward through every action), action replay (re-dispatch recorded actions), state diffing (see exactly what changed between actions), action filtering and search, and state export/import for bug reproduction. For teams that need to debug complex state transitions, this is a genuine competitive advantage over other libraries.

Zustand DevTools

Zustand connects to Redux DevTools via the devtools middleware. You get action logging and state inspection, but the experience is less native. Actions show up as generic set operations unless you name them explicitly:

set((state) => ({ count: state.count + 1 }), false, 'increment');
//                                            ^      ^
//                                   replace flag    action name for DevTools

Without the third argument, every state update appears as anonymous in DevTools. This is a common source of frustration when debugging.

Jotai DevTools

The jotai-devtools package provides a dedicated debugging UI with atom dependency graph visualization (see which atoms depend on which), atom value inspection, and the ability to set atom values directly from DevTools. The graph visualization is particularly useful — when a derived atom produces an unexpected value, you can trace its dependency chain visually.

Debugging Comparison

CapabilityRedux ToolkitZustandJotai
Time-travel debuggingNative, full supportVia Redux DevTools middlewareNot supported
Action replayYesLimitedNo
State diffYesVia Redux DevToolsAtom-level inspection
Dependency visualizationNoNoYes (atom graph)
Named actionsAutomatic from sliceManual (third arg to set)N/A (atom-based)
State export/importYesVia Redux DevToolsNo

Testing Patterns

Testing Redux

Redux's architecture makes testing straightforward because reducers are pure functions, selectors are pure functions, and thunks can be tested with a mock store.

// Test reducer directly
import cartReducer, { addItem, removeItem } from './cartSlice';

describe('cartSlice', () => {
  it('adds an item to empty cart', () => {
    const initial = { items: [], totalAmount: 0, isCheckingOut: false };
    const product = { id: '1', name: 'Shirt', price: 25, quantity: 1 };
    const state = cartReducer(initial, addItem(product));
    expect(state.items).toHaveLength(1);
    expect(state.totalAmount).toBe(25);
  });

  it('increments quantity for existing item', () => {
    const initial = {
      items: [{ id: '1', name: 'Shirt', price: 25, quantity: 1 }],
      totalAmount: 25,
      isCheckingOut: false,
    };
    const state = cartReducer(initial, addItem(initial.items[0]));
    expect(state.items[0].quantity).toBe(2);
    expect(state.totalAmount).toBe(50);
  });
});

// Test selector
import { selectFilteredItems } from './selectors';

it('filters by category', () => {
  const state = {
    products: { items: [{ id: '1', category: 'shirts' }, { id: '2', category: 'pants' }] },
    filters: { category: 'shirts' },
  };
  expect(selectFilteredItems(state)).toHaveLength(1);
});

Testing Zustand

Zustand stores are plain JavaScript. You can test them without rendering React components.

import { useCartStore } from './cartStore';

// Reset store before each test
beforeEach(() => {
  useCartStore.setState({ items: [], totalAmount: 0 });
});

describe('cartStore', () => {
  it('adds an item', () => {
    const product = { id: '1', name: 'Shirt', price: 25, quantity: 1 };
    useCartStore.getState().addItem(product);
    const state = useCartStore.getState();
    expect(state.items).toHaveLength(1);
    expect(state.totalAmount).toBe(25);
  });

  it('removes an item', () => {
    useCartStore.setState({
      items: [{ id: '1', name: 'Shirt', price: 25, quantity: 1 }],
      totalAmount: 25,
    });
    useCartStore.getState().removeItem('1');
    expect(useCartStore.getState().items).toHaveLength(0);
  });
});

The key advantage: no mock store, no provider wrapper, no renderHook. You call getState() and setState() directly. For component integration tests, Zustand works without any special setup since there's no provider to mount.

Testing Jotai

Jotai atoms are tested using a test provider or the createStore utility:

import { createStore } from 'jotai';
import { itemsAtom, filterAtom, filteredItemsAtom } from './atoms';

describe('filteredItemsAtom', () => {
  it('filters items by category', () => {
    const store = createStore();
    store.set(itemsAtom, [
      { id: '1', name: 'Shirt', category: 'clothing', price: 25, quantity: 1 },
      { id: '2', name: 'Laptop', category: 'electronics', price: 999, quantity: 1 },
    ]);
    store.set(filterAtom, 'clothing');

    const filtered = store.get(filteredItemsAtom);
    expect(filtered).toHaveLength(1);
    expect(filtered[0].name).toBe('Shirt');
  });

  it('returns all items when filter is "all"', () => {
    const store = createStore();
    store.set(itemsAtom, [
      { id: '1', name: 'Shirt', category: 'clothing', price: 25, quantity: 1 },
      { id: '2', name: 'Laptop', category: 'electronics', price: 999, quantity: 1 },
    ]);
    store.set(filterAtom, 'all');

    expect(store.get(filteredItemsAtom)).toHaveLength(2);
  });
});

Derived atoms are especially clean to test because you set input atoms, then read the derived atom. The dependency graph is exercised directly.

Migration Paths

Redux → Redux Toolkit

The simplest migration. RTK is backwards-compatible with vanilla Redux. You can migrate one slice at a time: convert a single reducer file to createSlice, keep the rest as-is, and they coexist in the same store. Most teams complete this incrementally with no feature flag or branching strategy needed.

Redux → Zustand

Store-by-store migration. Both can coexist in the same application — Redux via its provider, Zustand as module-level hooks. The typical pattern is to build new features with Zustand while gradually extracting Redux slices into Zustand stores during maintenance cycles. The conceptual shift is small: both are store-based, both use selectors.

Redux → Jotai

A more fundamental shift. Redux organizes state into centralized slices. Jotai organizes state as a graph of independent atoms. You can't easily translate a Redux slice into atoms — you need to rethink the state structure. This migration works best when done feature-by-feature, rebuilding each feature's state model as atoms from scratch rather than trying to mechanically convert.

Zustand ↔ Jotai

Despite different mental models, Zustand and Jotai share enough similarity that migration between them is moderate. Both are lightweight, both avoid boilerplate, and both are React-centric. The main difference is structural: a Zustand store becomes a set of Jotai atoms and derived atoms. Moving from Zustand to Jotai usually means decomposing stores into atoms, while moving from Jotai to Zustand means composing atoms into stores.

Decision Framework

Use this as a starting point, not a prescription. Every codebase has constraints these generalizations don't capture.

FactorRedux ToolkitZustandJotai
Team size >10Strong choice — standardized patterns, predictable code reviewPossible, but needs team conventionsPossible, but atom organization needs discipline
Complex async + cachingRTK Query handles this nativelyUse React Query alongsideUse jotai-tanstack-query
Simple UI stateOver-engineered for thisIdeal — minimal boilerplateGood — atoms are simple
Complex derived stateRequires Reselect, can get verboseRequires manual memoizationNative strength — atom dependency graph
Bundle sensitivity~13KB gzipped total~1KB gzipped~2KB gzipped
Debugging needsBest-in-class DevToolsAdequate via middlewareAtom graph is unique
Existing Redux codebaseMigrate to RTK firstCan coexist, migrate incrementallyRequires rethinking state design
Server state heavyRTK Query or pair with React QueryPair with React Query/SWRjotai-tanstack-query integration
Re-render optimizationRequires deliberate selector memoizationGood with selectorsBest default granularity

Case Study: E-Commerce Platform Migration

This case study covers a real migration on an e-commerce platform with 150+ React components, originally built with vanilla Redux — hand-written action type constants, action creator functions, and switch-statement reducers spread across 12 modules.

Phase 1: Redux Toolkit Migration (Weeks 1–6)

The first step was modernizing the existing Redux code. Each of the 12 Redux modules (cart, product catalog, user profile, orders, search, filters, notifications, wishlist, checkout, inventory, promotions, and admin) was converted to RTK slices.

The mechanical conversion was straightforward: switch statements became createSlice reducers, separate ACTION_TYPES files were deleted, and Immer replaced the spread-operator chains for immutable updates.

Measured results after Phase 1:

  • Boilerplate reduced by approximately 40%, measured by total lines of code across state management files (from 4,800 lines to 2,900 lines).
  • The custom API caching layer — a hand-built system of fetch wrappers, cache timestamps, and stale-while-revalidate logic — was replaced with RTK Query for the product catalog, user profile, and cart endpoints. This eliminated roughly 2,000 lines of custom fetch and cache code.
  • Time-travel debugging became available for the first time, since the old codebase had never configured Redux DevTools properly.

Phase 2: Zustand Evaluation for New Modules (Weeks 7–10)

Three new features were planned: a wishlist, a recently viewed products list, and a UI preferences system (sidebar state, table density, sort preferences). The Stripe Systems engineering team evaluated whether these features warranted full Redux slices or could use something lighter.

The evaluation criteria were bundle impact, re-render performance, implementation speed, and long-term maintainability. Each feature was prototyped in both Redux Toolkit and Zustand.

Benchmark results:

MetricRedux ToolkitZustand
Bundle addition13KB (already loaded, but counted for comparison)1.2KB
Re-renders on filter change12 components3 components
Boilerplate for CRUD store85 lines32 lines
Time to implement CRUD module4 hours1.5 hours

The re-render difference was significant. The Redux version triggered re-renders in 12 components because multiple useSelector calls across the component tree re-evaluated on every dispatched action — even with memoized selectors, the filter-change action caused cascading updates through connected components that depended on overlapping state slices. The Zustand version, with targeted selectors, limited re-renders to the 3 components that actually displayed the changed data.

Phase 3: Hybrid Approach (Weeks 11+)

Based on the evaluation, the team — with Stripe Systems engineers guiding the architecture — adopted a hybrid approach with clear boundaries:

Redux Toolkit + RTK Query for:

  • Server-synced state: cart, orders, user profile, product catalog
  • Complex multi-step workflows: checkout flow (5 steps with interdependent validation)
  • State requiring audit trails: order modifications, payment state transitions

Zustand for:

  • UI state: sidebar open/closed, modal stack, active theme, table column visibility
  • Ephemeral client state: search filters, sort order, recently viewed products
  • Feature-scoped state: wishlist (client-only with periodic server sync)

The team codified this into a decision matrix for evaluating new features:

QuestionIf Yes →
Does it need server sync with caching?RTK Query
Does it need time-travel debugging or action logging?Redux Toolkit
Is it UI-only state with no server persistence?Zustand
Is it shared across many unrelated components?Redux Toolkit or Zustand (depending on complexity)
Is it scoped to a single feature?Zustand

Results After 6 Months

Bundle size: Net reduction of 18KB. The custom caching layer removal saved more than Zustand added. Three feature modules that would have been Redux slices were implemented as Zustand stores, avoiding incremental Redux overhead.

Developer satisfaction: An internal team survey (14 respondents) showed 85% preferred the hybrid approach over the previous all-Redux architecture. The primary reasons cited were faster implementation of simple features and less cognitive overhead when working on UI state.

New feature delivery time: Reduced by approximately 30% for features using Zustand. This was measured across 8 feature tickets, comparing estimated-vs-actual completion times against historical Redux-only feature tickets of similar scope.

Onboarding: New developers reported that Zustand stores were easier to understand on first reading. Redux slices with RTK Query required more context about the tag invalidation system and middleware chain.

The hybrid approach isn't free of downsides. Two state management systems mean two sets of patterns to learn, and the boundary between "use Redux" and "use Zustand" requires judgment calls that sometimes slow down initial planning. But the team consensus was that this cost was outweighed by the benefits of using the right tool for each category of state.

Conclusion

There is no universally correct choice among Redux Toolkit, Zustand, and Jotai. Each library makes different tradeoffs along the axes of structure, performance defaults, debugging capability, and API surface area. Redux Toolkit provides the most structure and the best debugging tools at the cost of boilerplate and bundle size. Zustand provides the least friction at the cost of fewer built-in guardrails. Jotai provides the finest performance granularity and the most natural model for derived state, at the cost of requiring a different mental model than most developers are accustomed to.

The practical recommendation: start with the library that matches your team's primary pain point. If you need structure and auditability, start with Redux Toolkit. If you need speed and simplicity, start with Zustand. If your state is heavily derived and interconnected, start with Jotai. And don't treat the choice as permanent — these libraries coexist well, and a hybrid approach may ultimately serve a large codebase better than any single tool.

Ready to discuss your project?

Get in Touch →
← Back to Blog

More Articles