I spent about three weeks migrating a React application from Redux Toolkit to Zustand earlier this year. The app had around 40 components, 12 slices of state, and a few hundred users. It wasn't huge, but it was complex enough to feel the real differences between the two libraries.
This post covers what I actually learned — what got better, what got worse, where Zustand surprised me, and where I missed Redux. Plus working code examples for the patterns that mattered most.
The Quick Verdict
Use Zustand if: You want minimal boilerplate, you're working on a small-to-medium app, your team is comfortable making decisions about state organization themselves, and you don't need extensive middleware ecosystems.
*Use Redux Toolkit if: * You're working on a large app, you need predictable state management across a large team, you rely on the DevTools extensively, or you need middleware like Redux Saga / Redux Observable.
The decision usually comes down to team size and predictability needs, not technical capability. Both libraries can build the same applications. They just have different opinions about how much structure you need.
Why I Migrated (Honestly)
I'll be upfront about my reasons because they affect what you should take from this comparison.
The Redux Toolkit codebase wasn't broken. It worked. The migration wasn't driven by a performance problem or a bug. It was driven by team friction.
Our team had grown from 2 developers to 5 in six months. The new developers found Redux Toolkit's mental model intimidating — even Redux Toolkit, which is the friendliest Redux has ever been. They struggled with concepts like slices, reducers, actions, selectors, and the dispatch pattern. Every state change required reading through 3-4 files.
Three of our newer developers had used Zustand on side projects and kept asking why we couldn't use it at work.
Eventually I gave in and ran a proof-of-concept migration on one feature. It went well. We migrated the rest over three sprints.
This is the most important context for everything that follows: I migrated because of team friction, not because Redux was technically worse.
The Core Differences in Code
Before getting into lessons learned, let me show the same feature in both libraries. This is a simple shopping cart store.
Redux Toolkit Version
// store/slices/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
isLoading: boolean;
}
const initialState: CartState = {
items: [],
isLoading: false,
};
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem: (state, action: PayloadAction<CartItem>) => {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
existing.quantity += action.payload.quantity;
} else {
state.items.push(action.payload);
}
},
removeItem: (state, action: PayloadAction<string>) => {
state.items = state.items.filter(i => i.id !== action.payload);
},
clearCart: (state) => {
state.items = [];
},
},
});
export const { addItem, removeItem, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './slices/cartSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Component usage
import { useSelector, useDispatch } from 'react-redux';
import { addItem, removeItem } from './store/slices/cartSlice';
import { RootState, AppDispatch } from './store';
function CartButton({ product }) {
const dispatch = useDispatch<AppDispatch>();
const items = useSelector((state: RootState) => state.cart.items);
return (
<button onClick={() => dispatch(addItem({ ...product, quantity: 1 }))}>
Add to Cart ({items.length})
</button>
);
}
Zustand Version (Same Feature)
// stores/useCartStore.ts
import { create } from 'zustand';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
isLoading: boolean;
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
export const useCartStore = create<CartStore>((set) => ({
items: [],
isLoading: false,
addItem: (item) => set((state) => {
const existing = state.items.find(i => i.id === item.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i
),
};
}
return { items: [...state.items, item] };
}),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id),
})),
clearCart: () => set({ items: [] }),
}));
// Component usage
import { useCartStore } from './stores/useCartStore';
function CartButton({ product }) {
const items = useCartStore((state) => state.items);
const addItem = useCartStore((state) => state.addItem);
return (
<button onClick={() => addItem({ ...product, quantity: 1 })}>
Add to Cart ({items.length})
</button>
);
}
What the Code Comparison Shows
The Zustand version is roughly 40% less code. No separate slice files. No store configuration. No Provider wrapper needed in your app root. No typed dispatch hook. No RootState type to maintain.
For a small feature, that's significant. For a complex application, the savings compound.
But code length isn't the only thing that matters. Let me get into what actually happened during the migration.
What Got Better After Migration
1. Onboarding Time Dropped Significantly
The biggest win wasn't technical — it was team-related. New developers became productive faster.
With Redux Toolkit, a new developer needed to learn slices, actions, reducers, selectors, thunks, the dispatch pattern, and how all of these connected. Even with good documentation, that's a lot.
With Zustand, the mental model is: "It's just a custom hook with state in it." That sentence is the entire architecture.
Onboarding time for state management went from about 2 days to about 2 hours. For a growing team, this matters more than I expected.
2. File Count Dropped by Roughly 60%
The Redux Toolkit version of our app had a store folder with 12 slice files, 3 middleware files, a root reducer, and a store configuration file. Plus typed hooks in another file. Plus selectors organized by domain.
The Zustand version has 12 store files, each fully self-contained. That's it.
For a small to medium app, fewer files means less cognitive overhead. New developers can navigate the codebase faster.
3. TypeScript Inference Got Better
Both libraries support TypeScript well, but Zustand's inference is noticeably better for selectors.
In Redux Toolkit, you write:
const items = useSelector((state: RootState) => state.cart.items);
You have to manually annotate RootState because the selector doesn't know what shape state has.
In Zustand, you write:
const items = useCartStore((state) => state.items);
TypeScript infers state from the store definition. No manual RootState maintenance. When you add a new field to a store, every selector using it gets type-checked automatically without you updating a separate types file.
4. Testing Got Simpler
Testing Redux requires setting up a test store, wrapping components in a Provider, and dispatching actions. Even with Redux Toolkit's setupListeners and test utilities, there's setup.
Testing Zustand: just call useCartStore.getState().addItem({...}) in a test and assert against useCartStore.getState().items. No provider, no mock store, no dispatching.
For unit tests, this saved time. For component integration tests, the savings were smaller because you still mount the component either way.
5. Bundle Size Decreased
The Redux Toolkit + React Redux combination adds about 12kb gzipped to a bundle. Zustand adds about 1kb gzipped.
For most apps, this doesn't matter. But if you're optimizing aggressively (mobile, slow networks, edge computing), it's a real difference.
What Got Worse After Migration
This is the part most migration blog posts skip. Here's what I miss about Redux.
1. The Redux DevTools Are Better
Redux DevTools is genuinely incredible. Time-travel debugging, action replay, state diffing — it's a mature, polished tool.
Zustand has DevTools support via middleware (zustand/middleware/devtools), but it's noticeably less polished. Time-travel works, but the UX for action history is rougher. State diffing is less detailed.
If you debug heavily through DevTools, this is a real downgrade.
2. The "Single Source of Truth" Principle Weakened
In Redux, all state lives in one store. There's exactly one place to look for any piece of state.
In Zustand, the pattern is multiple small stores. We had 12 stores after migration. The trade-off is flexibility — but the cost is that "where does X state live?" becomes a question new developers ask.
This can be solved by writing internal conventions ("user state goes in useUserStore, never anywhere else"), but Redux enforces this structurally. Zustand requires discipline.
3. Middleware Ecosystem Is Smaller
Redux has Redux Saga, Redux Observable, Redux Logger, Redux Persist, RTK Query, and dozens of other mature middleware libraries. Zustand has middleware for persistence, DevTools, immer, and a smaller set of community options.
We hit this problem when we wanted to add complex async logic that we'd previously handled with Redux Toolkit's createAsyncThunk + RTK Query. Zustand has options, but they're less mature.
4. Predictability for Large Teams Decreased
This is the same point as "single source of truth" from a different angle. Redux's verbose structure forces a specific architecture. Zustand lets you do whatever you want.
In a 5-person team, this was fine. If our team grew to 15, I'd be nervous. Without enforced patterns, every developer would solve state problems slightly differently, and the codebase would diverge.
For teams over 10 developers, I'd consider this a serious downside.
5. Async Patterns Are Less Standardized
Redux Toolkit Query (RTK Query) is one of the best data fetching solutions in any framework. The cache invalidation, automatic refetching, optimistic updates, and TypeScript integration are all excellent.
Zustand has no equivalent built-in. We ended up using TanStack Query (React Query) alongside Zustand, which works well but adds another dependency to learn.
If you're already using RTK Query, replacing it with TanStack Query + Zustand is a lateral move at best.
The Patterns I Wish I'd Known Earlier
Three Zustand patterns saved significant time once I learned them.
Pattern 1: Selectors with shallow for Performance
By default, Zustand triggers a re-render any time the selected state changes. If you select an object, every property change re-renders.
// This re-renders any time anything in the cart changes
const { items, isLoading, totalPrice } = useCartStore((state) => ({
items: state.items,
isLoading: state.isLoading,
totalPrice: state.totalPrice,
}));
Use shallow to compare equality field-by-field:
import { shallow } from 'zustand/shallow';
const { items, isLoading, totalPrice } = useCartStore(
(state) => ({
items: state.items,
isLoading: state.isLoading,
totalPrice: state.totalPrice,
}),
shallow
);
Without shallow, this pattern causes unnecessary re-renders. With it, performance matches what Redux's useSelector does by default.
Pattern 2: Persisting State to localStorage
This was much easier than I expected.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
export const useCartStore = create(
persist<CartStore>(
(set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id),
})),
}),
{
name: 'cart-storage',
storage: createJSONStorage(() => localStorage),
}
)
);
Three lines of middleware setup and the entire store persists automatically. No redux-persist configuration. No rehydration logic to write.
Pattern 3: Computed/Derived State
Zustand doesn't have a useSelector equivalent for derived state, but you can compute it inside the selector:
// Computed total price
const totalPrice = useCartStore((state) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
This recomputes only when items changes. For more complex derived state, you can use useMemo:
const items = useCartStore((state) => state.items);
const totalPrice = useMemo(
() => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
[items]
);
I missed reselect initially but ended up not needing it.
Migration Strategy That Actually Worked
If you're considering this migration, here's the approach that worked for us.
Step 1: Start With One Feature
Pick a self-contained feature (we used the shopping cart). Migrate it to Zustand while keeping Redux running for everything else. Both libraries can coexist in the same app.
Step 2: Run Both for a Sprint
Live with both libraries for at least a sprint. This reveals friction points you wouldn't notice in a small POC.
Step 3: Migrate Stores One at a Time
Pick stores in order of independence. Migrate the ones with fewest connections first. Don't try to migrate everything at once.
Step 4: Migrate Selectors and Hooks Together
When you migrate a store, migrate every component using it in the same PR. Don't leave a state mid-migration. It creates confusing code where some components use useSelector and others use useCartStore.'
Step 5: Remove Redux Last
Only remove @reduxjs/toolkit and react-redux from package.json after every store is migrated. Run the app one more time to confirm nothing breaks.
For us, this took three weeks of part-time work alongside normal feature development. It would have taken about two weeks if we'd dedicated full focus.
When NOT to Migrate
Be honest with yourself if any of these apply:
- Your team is happy with Redux Toolkit. "It would be nice" isn't a strong enough reason. Migrations have real costs.
- You have heavy RTK Query usage. TanStack Query is great but the migration cost is high.
- You depend on Redux DevTools for debugging. Zustand's DevTools are real but inferior.
- Your team is large (10+). The structural enforcement Redux provides becomes more valuable as team size grows.
- You have complex async middleware (Sagas, Observables). Zustand has no equivalent.
In all of these cases, sticking with Redux Toolkit is the smarter choice.
My Honest Recommendation
For small-to-medium apps with small-to-medium teams: Zustand wins. It's less code, faster onboarding, simpler testing, smaller bundle. The trade-offs (worse DevTools, less enforced structure) are minor at this scale.
For large apps with large teams: Redux Toolkit is still the better choice. The enforced structure becomes a feature, not a bug. The mature ecosystem matters more. The DevTools matter more.
The migration isn't a "Zustand is better" story — it's a "different tools for different stages" story. We migrated because our stage changed. If our team grew to 15 next year, we'd seriously consider migrating back.
Both libraries are good. Pick the one that fits the team you have today.
_If you're considering this migration or already did one, I'd love to hear what went differently for your team. The decision is much more about team dynamics than tech specs, and I think we under-discuss that.
_
About Exact Solution:
Exact Solution is an e-commerce store specializing in refurbished electronics — laptops, smartphones, tablets, and game consoles. Our team writes about the technical decisions behind building and scaling our platform, sharing practical lessons from running a modern product business.
Browse our refurbished laptops, refurbished smartphones, or visit exactsolution.com.
Top comments (0)