Difficulty: Intermediate
Compare Zustand and Redux. How do you structure state management in a production React app?
State management is one of the most debated topics in the React ecosystem, and understanding the trade-offs between Zustand and Redux will serve you well in both interviews and real projects. Let me give you the full picture.
First, why do external state management libraries exist at all? React has built-in state management with useState and useContext. For many apps, this is enough. But as your app grows, you run into problems: prop drilling (passing state through many component layers that do not use it), performance issues with Context (every consumer re-renders when any part of the context value changes), no built-in way to share state between unrelated components, and difficulty coordinating multiple related state updates. State management libraries solve these problems with different philosophies.
Zustand takes the minimalist approach. Its design philosophy is 'do the minimum possible.' A Zustand store is created with the create() function, which takes a callback defining your state and actions. The store itself is a hook. You call useStore(selector) in your component, passing a selector function that picks out just the piece of data you need. The brilliant part is that Zustand only re-renders your component when the selected value actually changes. If the store has 20 properties but you only select user.name, changes to the other 19 properties are ignored. No Provider component needed, no boilerplate, no action types, no reducers.
What makes Zustand feel refreshing compared to Redux: to create a store, you write one function. To use it, you call it like a hook. To update it, you call the action functions you defined. That is the entire API. The learning curve is essentially flat.
Zustand supports middleware through composition. devtools connects to Redux DevTools (yes, Zustand works with Redux DevTools). persist automatically saves state to localStorage and rehydrates on page load. immer lets you write mutable-style updates that produce immutable state. Another underappreciated feature: Zustand stores work outside React components. You can call useStore.getState() and useStore.setState() from API interceptors, utility functions, or plain JavaScript modules.
Redux Toolkit (RTK) is the modern, opinionated way to use Redux. The philosophy is different: Redux values explicitness and predictability. Every state change follows a strict flow -- an action is dispatched, a reducer processes it and returns new state, and middleware can intercept actions for side effects. In RTK, createSlice generates action creators automatically, includes Immer for mutable-style syntax, and configureStore sets up sensible defaults.
The trade-offs come down to team size and complexity. Zustand is better for small to medium teams: less code, faster development, easier to learn. Redux Toolkit is better for large teams: strict patterns make the codebase predictable when many developers work on it, the powerful devtools (time-travel debugging, action replay) help with debugging, and the ecosystem is massive.
Now, here is the architectural insight that matters most: the distinction between client state and server state. Client state is data that only exists in the browser -- UI toggles, form state, navigation state, theme preferences. Server state is data from your backend -- user profiles, product lists, order history. These two types have fundamentally different characteristics. Client state is synchronous and local. Server state is asynchronous, shared across users, and can become stale.
The modern best practice is to use different tools for each. Use Zustand or Redux for client state: UI toggles, auth tokens, theme preferences. Use React Query (TanStack Query) for server state: API data with automatic caching, background refetching, stale data handling, pagination, and optimistic updates. This combination is powerful because each tool handles what it is best at.
A common mistake is putting everything into global state. Most state should be local to a component (useState) or a small subtree. Global state should be reserved for data genuinely needed across unrelated parts of the app.
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
const useAuthStore = create(
devtools(
persist(
immer((set, get) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const { user, token } = await res.json();
// Immer lets you write mutable code
set(state => {
state.user = user;
state.token = token;
state.isAuthenticated = true;
});
},
logout: () => set(state => {
state.user = null;
state.token = null;
state.isAuthenticated = false;
}),
updateProfile: (updates) => set(state => {
Object.assign(state.user, updates);
}),
})),
{ name: 'auth-store' } // localStorage key
),
{ name: 'AuthStore' } // devtools label
)
);
// Usage with selectors
function Navbar() {
const user = useAuthStore(s => s.user);
const logout = useAuthStore(s => s.logout);
if (!user) return <LoginButton />;
return <span>{user.name} <button onClick={logout}>Logout</button></span>;
}
Zustand middleware composes naturally. persist saves to localStorage, devtools connects to Redux DevTools, and immer enables mutable-style updates. Selectors prevent unnecessary re-renders.
import { createSlice, configureStore } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux';
// Create a slice with auto-generated actions
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
total: 0,
},
reducers: {
addItem: (state, action) => {
// Immer is built in - write mutable code
state.items.push(action.payload);
state.total += action.payload.price;
},
removeItem: (state, action) => {
const index = state.items.findIndex(i => i.id === action.payload);
if (index !== -1) {
state.total -= state.items[index].price;
state.items.splice(index, 1);
}
},
clearCart: (state) => {
state.items = [];
state.total = 0;
},
},
});
export const { addItem, removeItem, clearCart } = cartSlice.actions;
// Configure store
const store = configureStore({
reducer: {
cart: cartSlice.reducer,
},
});
// Usage with typed hooks
function CartSummary() {
const { items, total } = useSelector(state => state.cart);
const dispatch = useDispatch();
return (
<div>
<p>{items.length} items - ${total.toFixed(2)}</p>
<button onClick={() => dispatch(clearCart())}>Clear</button>
</div>
);
}
Redux Toolkit reduces boilerplate significantly. createSlice auto-generates action creators, uses Immer internally, and follows standard Redux patterns.
// Real-world architecture: Zustand for UI state + React Query for server state
// UI State Store (Zustand)
const useUIStore = create((set) => ({
sidebarOpen: true,
theme: 'light',
activeModal: null,
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme) => set({ theme }),
openModal: (modal) => set({ activeModal: modal }),
closeModal: () => set({ activeModal: null }),
}));
// Auth Store (Zustand with persist)
const useAuthStore = create(persist((set) => ({
user: null,
token: null,
setAuth: (user, token) => set({ user, token }),
clearAuth: () => set({ user: null, token: null }),
}), { name: 'auth' }));
// Server State (React Query)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function useProducts(filters) {
return useQuery({
queryKey: ['products', filters],
queryFn: () => api.getProducts(filters),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
// Component uses all three
function ProductPage() {
const theme = useUIStore(s => s.theme);
const user = useAuthStore(s => s.user);
const { data: products, isLoading } = useProducts({ category: 'all' });
// Clean separation: UI state + Auth state + Server state
}
Production apps typically combine Zustand for UI/auth state and React Query for server state. This gives you the best of both worlds: simple client state management and powerful data fetching with caching.
Zustand, Redux Toolkit, Middleware, Selectors, Devtools