import ComparisonTable from ’../../components/ComparisonTable.astro’;
State management is one of React’s most discussed topics. Redux dominated for years; Zustand emerged as a simpler alternative. Both now have excellent TypeScript support and developer tools — the choice comes down to architecture philosophy.
Quick Verdict
Choose Zustand if: You’re building a new project and want minimal setup, your team values simplicity, or you find Redux boilerplate frustrating.
Choose Redux Toolkit if: You have a large team, need strong conventions enforced, or are maintaining an existing Redux codebase.
Feature Comparison
<ComparisonTable headers={[“Feature”, “Zustand”, “Redux Toolkit”]} rows={[ [“API surface”, “Minimal (~1KB)”, “Larger (actions, reducers, slices)”], [“Boilerplate”, “Very low”, “Low (RTK reduced it significantly)”], [“Provider required”, “No”, “Yes (Provider wrapper)”], [“DevTools”, “Redux DevTools compatible”, “Native Redux DevTools”], [“TypeScript”, “Excellent inference”, “Excellent (well-typed)”], [“Async handling”, “Direct in actions”, “createAsyncThunk”], [“Middleware”, “Middleware array”, “Middleware array”], [“Selectors”, “Direct state access”, “createSelector (memoized)”], [“Immer”, “Optional plugin”, “Built-in”], [“Bundle size”, “~1KB”, “~10KB”], ]} />
Store Definition
Zustand — minimal setup:
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
isOpen: boolean;
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
toggleCart: () => void;
totalItems: () => number;
totalPrice: () => number;
}
export const useCartStore = create<CartState>()(
devtools(
persist(
(set, get) => ({
items: [],
isOpen: 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 + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id),
})),
updateQuantity: (id, quantity) => set((state) => ({
items: quantity === 0
? state.items.filter(i => i.id !== id)
: state.items.map(i => i.id === id ? { ...i, quantity } : i),
})),
clearCart: () => set({ items: [] }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
// Computed values as methods
totalItems: () => get().items.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{ name: 'cart-storage' } // Persist to localStorage
)
)
);
// Usage in component — no Provider needed
function CartButton() {
const { totalItems, toggleCart } = useCartStore();
return (
<button onClick={toggleCart}>
Cart ({totalItems()})
</button>
);
}
Redux Toolkit — more structure:
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { configureStore } from '@reduxjs/toolkit';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
isOpen: boolean;
status: 'idle' | 'loading' | 'failed';
}
const initialState: CartState = {
items: [],
isOpen: false,
status: 'idle',
};
// Async thunk for API calls
export const fetchCartFromServer = createAsyncThunk(
'cart/fetchFromServer',
async (userId: string) => {
const response = await api.get(`/cart/${userId}`);
return response.data as CartItem[];
}
);
export const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem(state, action: PayloadAction<Omit<CartItem, 'quantity'>>) {
// Immer enabled — direct mutation syntax
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removeItem(state, action: PayloadAction<string>) {
state.items = state.items.filter(i => i.id !== action.payload);
},
updateQuantity(state, action: PayloadAction<{ id: string; quantity: number }>) {
const item = state.items.find(i => i.id === action.payload.id);
if (item) {
item.quantity = action.payload.quantity;
}
},
clearCart(state) {
state.items = [];
},
toggleCart(state) {
state.isOpen = !state.isOpen;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchCartFromServer.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchCartFromServer.fulfilled, (state, action) => {
state.status = 'idle';
state.items = action.payload;
})
.addCase(fetchCartFromServer.rejected, (state) => {
state.status = 'failed';
});
},
});
export const { addItem, removeItem, updateQuantity, clearCart, toggleCart } = cartSlice.actions;
// Selectors
export const selectTotalItems = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.quantity, 0);
export const selectTotalPrice = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// Store configuration
export const store = configureStore({
reducer: {
cart: cartSlice.reducer,
// other slices...
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// App.tsx — Provider required
import { Provider } from 'react-redux';
import { store } from './store';
function App() {
return (
<Provider store={store}>
<CartButton />
</Provider>
);
}
// CartButton.tsx
import { useSelector, useDispatch } from 'react-redux';
import { toggleCart } from './cartSlice';
import { selectTotalItems } from './cartSlice';
function CartButton() {
const totalItems = useSelector(selectTotalItems);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(toggleCart())}>
Cart ({totalItems})
</button>
);
}
Async Data Fetching
Both libraries work well with server state libraries (TanStack Query, SWR), but handle local async differently:
Zustand async:
interface UserStore {
user: User | null;
loading: boolean;
error: string | null;
fetchUser: (id: string) => Promise<void>;
}
export const useUserStore = create<UserStore>()((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null });
try {
const user = await api.getUser(id);
set({ user, loading: false });
} catch (error) {
set({ loading: false, error: error.message });
}
},
}));
Redux createAsyncThunk:
// More structured but more code
export const fetchUser = createAsyncThunk(
'user/fetch',
async (id: string, { rejectWithValue }) => {
try {
return await api.getUser(id);
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// Slice handles all three states automatically
// (pending, fulfilled, rejected) via extraReducers
Performance
Both perform well. The key difference is subscription granularity:
Zustand — subscribe to slices:
// Only re-render when items changes, not when isOpen changes
const items = useCartStore(state => state.items);
// Shallow equality check for object/array comparison
import { shallow } from 'zustand/shallow';
const { items, isOpen } = useCartStore(
state => ({ items: state.items, isOpen: state.isOpen }),
shallow
);
Redux — createSelector for memoization:
import { createSelector } from '@reduxjs/toolkit';
// Memoized selector — only recomputes when items change
const selectExpensiveItems = createSelector(
(state: RootState) => state.cart.items,
(items) => items.filter(item => item.price > 100)
);
// Component only re-renders when selector result changes
const expensiveItems = useSelector(selectExpensiveItems);
Developer Tools
Both integrate with Redux DevTools (browser extension):
// Zustand — add devtools middleware
import { devtools } from 'zustand/middleware';
const useStore = create(devtools((set) => ({
// ...
}), { name: 'MyStore' }));
// Redux — built-in DevTools support
const store = configureStore({
reducer: rootReducer,
// DevTools enabled automatically in development
});
Redux DevTools has richer time-travel debugging. Both show state changes and actions.
When to Choose Each
Choose Zustand:
- New projects (minimal setup)
- Small to medium teams
- When you want flexibility over convention
- Prototypes and side projects
- When Provider nesting bothers you
Choose Redux Toolkit:
- Large teams needing enforced patterns
- Complex state with many async flows
- Enterprise apps with strict architecture requirements
- Teams who prefer the strict action/reducer pattern
- Migrating an existing Redux codebase
Alternatives Worth Knowing
Jotai — Atomic state management (Recoil-style, better):
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
TanStack Query — For server state (not UI state):
const { data, isPending } = useQuery({
queryKey: ['user', userId],
queryFn: () => api.getUser(userId),
});
The modern recommendation: TanStack Query for server state, Zustand for client state. This combination handles 90% of state management needs with minimal boilerplate.
Bottom Line
Zustand has earned its place as the pragmatic default for new React projects. It’s simpler, requires less code, and is easier to learn than Redux Toolkit while still being capable. Redux Toolkit remains the better choice for large teams who benefit from its stricter conventions and more structured approach. Start with Zustand; switch to Redux Toolkit only if your team’s coordination needs demand it.