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
| Scenario | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|
| Single item update in list of 500 | All items re-render (without memoization), 1 item (with React.memo + stable keys) | Only subscribers to that item's data | Only components reading that item's atom |
| Filter change | All useSelector consumers re-evaluate; re-render depends on selector memoization | Components selecting the filtered result re-render | Components 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 state | Only components reading themeAtom |
| Adding item to cart while viewing product list | Product list components re-evaluate selectors | No re-render if product list doesn't select cart state | No 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
| Capability | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|
| Time-travel debugging | Native, full support | Via Redux DevTools middleware | Not supported |
| Action replay | Yes | Limited | No |
| State diff | Yes | Via Redux DevTools | Atom-level inspection |
| Dependency visualization | No | No | Yes (atom graph) |
| Named actions | Automatic from slice | Manual (third arg to set) | N/A (atom-based) |
| State export/import | Yes | Via Redux DevTools | No |
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.
| Factor | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|
| Team size >10 | Strong choice — standardized patterns, predictable code review | Possible, but needs team conventions | Possible, but atom organization needs discipline |
| Complex async + caching | RTK Query handles this natively | Use React Query alongside | Use jotai-tanstack-query |
| Simple UI state | Over-engineered for this | Ideal — minimal boilerplate | Good — atoms are simple |
| Complex derived state | Requires Reselect, can get verbose | Requires manual memoization | Native strength — atom dependency graph |
| Bundle sensitivity | ~13KB gzipped total | ~1KB gzipped | ~2KB gzipped |
| Debugging needs | Best-in-class DevTools | Adequate via middleware | Atom graph is unique |
| Existing Redux codebase | Migrate to RTK first | Can coexist, migrate incrementally | Requires rethinking state design |
| Server state heavy | RTK Query or pair with React Query | Pair with React Query/SWR | jotai-tanstack-query integration |
| Re-render optimization | Requires deliberate selector memoization | Good with selectors | Best 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:
| Metric | Redux Toolkit | Zustand |
|---|---|---|
| Bundle addition | 13KB (already loaded, but counted for comparison) | 1.2KB |
| Re-renders on filter change | 12 components | 3 components |
| Boilerplate for CRUD store | 85 lines | 32 lines |
| Time to implement CRUD module | 4 hours | 1.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:
| Question | If 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 →