State management in React is how an application stores and shares data across components, and the three most popular libraries — Redux Toolkit, Zustand, and Jotai — solve it with very different philosophies. Redux Toolkit is the mature, convention-heavy choice with excellent devtools, best suited to large enterprise apps; Zustand is a minimal store-based library with almost no boilerplate and no providers, ideal for small to medium projects; Jotai takes an atomic approach where each piece of state is a composable atom, giving fine-grained re-renders that shine in form-heavy UIs. Bundle sizes tell part of the story: Zustand and Jotai each weigh roughly 3KB minified and gzipped, while Redux Toolkit is around 12KB. Before adopting any of them, start with useState, useReducer, and Context, and add a library only when prop drilling or shared state genuinely hurts. This comparison shows each with working code.
When Do You Need State Management?
Consider global state management when:
- Multiple unrelated components need the same data
- State needs to persist across route changes
- You have complex state update logic
- You need time-travel debugging or state persistence
- Props drilling becomes unmanageable
Start with local state (useState, useReducer) and React Context. Only add a state management library when you have a real need.
Redux Toolkit
Redux is the most established solution with a large ecosystem. Redux Toolkit (RTK) simplifies the traditional Redux boilerplate significantly.
Setup
npm install @reduxjs/toolkit react-reduxCreating a Slice
// store/slices/userSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface User {
id: string;
name: string;
email: string;
}
interface UserState {
user: User | null;
isLoading: boolean;
error: string | null;
}
const initialState: UserState = {
user: null,
isLoading: false,
error: null,
};
// Async thunk for API calls
export const fetchUser = createAsyncThunk(
'user/fetchUser',
async (userId: string, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
} catch (error) {
return rejectWithValue((error as Error).message);
}
}
);
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUser: (state, action: PayloadAction<User>) => {
state.user = action.payload;
},
clearUser: (state) => {
state.user = null;
},
updateUserName: (state, action: PayloadAction<string>) => {
if (state.user) {
state.user.name = action.payload;
}
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.isLoading = false;
state.user = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
});
export const { setUser, clearUser, updateUserName } = userSlice.actions;
export default userSlice.reducer;Store Configuration
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';
import cartReducer from './slices/cartSlice';
export const store = configureStore({
reducer: {
user: userReducer,
cart: cartReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false, // Disable for non-serializable data
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Typed hooks
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;Using in Components
// components/UserProfile.tsx
import { useAppSelector, useAppDispatch } from '../store';
import { fetchUser, clearUser } from '../store/slices/userSlice';
import { useEffect } from 'react';
export function UserProfile({ userId }: { userId: string }) {
const dispatch = useAppDispatch();
const { user, isLoading, error } = useAppSelector((state) => state.user);
useEffect(() => {
dispatch(fetchUser(userId));
}, [dispatch, userId]);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={() => dispatch(clearUser())}>Logout</button>
</div>
);
}Zustand
Zustand is a minimal, unopinionated state management library. It's simple to set up and has excellent TypeScript support.
Setup
npm install zustandCreating a Store
// store/userStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface User {
id: string;
name: string;
email: string;
}
interface UserState {
user: User | null;
isLoading: boolean;
error: string | null;
// Actions
setUser: (user: User) => void;
clearUser: () => void;
fetchUser: (userId: string) => Promise<void>;
}
export const useUserStore = create<UserState>()(
devtools(
persist(
(set) => ({
user: null,
isLoading: false,
error: null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
fetchUser: async (userId) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const user = await response.json();
set({ user, isLoading: false });
} catch (error) {
set({ error: (error as Error).message, isLoading: false });
}
},
}),
{
name: 'user-storage', // localStorage key
partialize: (state) => ({ user: state.user }), // Only persist user
}
)
)
);Computed Values & Selectors
// store/cartStore.ts
import { create } from 'zustand';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
}
export const useCartStore = create<CartState>((set) => ({
items: [],
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: state.items.map((i) =>
i.id === id ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [] }),
}));
// Selectors (outside the store for reusability)
export const selectCartTotal = (state: CartState) =>
state.items.reduce((total, item) => total + item.price * item.quantity, 0);
export const selectCartItemCount = (state: CartState) =>
state.items.reduce((count, item) => count + item.quantity, 0);Using in Components
// components/Cart.tsx
import { useCartStore, selectCartTotal, selectCartItemCount } from '../store/cartStore';
export function Cart() {
const items = useCartStore((state) => state.items);
const total = useCartStore(selectCartTotal);
const itemCount = useCartStore(selectCartItemCount);
const { removeItem, updateQuantity, clearCart } = useCartStore();
return (
<div>
<h2>Cart ({itemCount} items)</h2>
{items.map((item) => (
<div key={item.id}>
<span>{item.name} - ${item.price} x {item.quantity}</span>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}>-</button>
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
<p>Total: ${total.toFixed(2)}</p>
<button onClick={clearCart}>Clear Cart</button>
</div>
);
}
// Optimized: Only re-render when itemCount changes
function CartIcon() {
const itemCount = useCartStore(selectCartItemCount);
return <span>🛒 {itemCount}</span>;
}Jotai
Jotai uses an atomic approach to state management. Each piece of state is an atom that can be composed together.
Setup
npm install jotaiCreating Atoms
// atoms/userAtoms.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
interface User {
id: string;
name: string;
email: string;
}
// Basic atom
export const userAtom = atom<User | null>(null);
// Atom with localStorage persistence
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
// Derived atom (computed value)
export const isAuthenticatedAtom = atom((get) => get(userAtom) !== null);
// Async atom
export const userDataAtom = atom(async (get) => {
const user = get(userAtom);
if (!user) return null;
const response = await fetch(`/api/users/${user.id}/profile`);
return response.json();
});
// Write-only atom (action)
export const logoutAtom = atom(null, (get, set) => {
set(userAtom, null);
localStorage.removeItem('token');
});Cart Example with Jotai
// atoms/cartAtoms.ts
import { atom } from 'jotai';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
// Base atom
export const cartItemsAtom = atom<CartItem[]>([]);
// Derived atoms
export const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((total, item) => total + item.price * item.quantity, 0);
});
export const cartCountAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((count, item) => count + item.quantity, 0);
});
// Action atoms
export const addToCartAtom = atom(
null,
(get, set, item: Omit<CartItem, 'quantity'>) => {
const items = get(cartItemsAtom);
const existing = items.find((i) => i.id === item.id);
if (existing) {
set(
cartItemsAtom,
items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
)
);
} else {
set(cartItemsAtom, [...items, { ...item, quantity: 1 }]);
}
}
);
export const removeFromCartAtom = atom(null, (get, set, id: string) => {
const items = get(cartItemsAtom);
set(
cartItemsAtom,
items.filter((i) => i.id !== id)
);
});
export const clearCartAtom = atom(null, (get, set) => {
set(cartItemsAtom, []);
});Using in Components
// components/Cart.tsx
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import {
cartItemsAtom,
cartTotalAtom,
cartCountAtom,
removeFromCartAtom,
clearCartAtom,
} from '../atoms/cartAtoms';
export function Cart() {
const [items] = useAtom(cartItemsAtom);
const total = useAtomValue(cartTotalAtom);
const removeFromCart = useSetAtom(removeFromCartAtom);
const clearCart = useSetAtom(clearCartAtom);
return (
<div>
{items.map((item) => (
<div key={item.id}>
<span>{item.name} - ${item.price}</span>
<button onClick={() => removeFromCart(item.id)}>Remove</button>
</div>
))}
<p>Total: ${total.toFixed(2)}</p>
<button onClick={clearCart}>Clear</button>
</div>
);
}
// Only subscribes to count changes
function CartBadge() {
const count = useAtomValue(cartCountAtom);
return <span className="badge">{count}</span>;
}How Do Redux, Zustand, and Jotai Compare?
Redux Toolkit:
- Mature ecosystem with middleware support
- Great DevTools for debugging
- More boilerplate than alternatives
- Best for large, complex applications
- Strong conventions and patterns
Zustand:
- Minimal boilerplate, easy to learn
- No providers needed
- Built-in middleware (persist, devtools)
- Great for medium-sized applications
- Flexible and unopinionated
Jotai:
- Atomic model - fine-grained updates
- Minimal re-renders by design
- Excellent for form-heavy applications
- Composable atoms pattern
- Small bundle size
Which State Management Library Should You Choose?
Choose based on your needs:
- Small project: Start with Context + useReducer
- Medium project: Zustand (simple) or Jotai (atomic)
- Large enterprise app: Redux Toolkit
- Form-heavy app: Jotai
- Need persistence/middleware: Zustand or Redux
// Decision matrix
// Bundle size (minified + gzip)
// - Jotai: ~3KB
// - Zustand: ~3KB
// - Redux Toolkit: ~12KB
// Learning curve
// - Zustand: Low
// - Jotai: Low-Medium
// - Redux: Medium-High
// Boilerplate
// - Zustand: Minimal
// - Jotai: Minimal
// - Redux: Moderate (reduced with RTK)
// DevTools
// - Redux: Excellent
// - Zustand: Good (via middleware)
// - Jotai: Good (via devtools)
// TypeScript support
// - All three: ExcellentThe best state management solution is the one that fits your team's needs and project complexity. Don't reach for global state management until you actually need it. When you do, choose the simplest tool that solves your problem.
All three libraries are excellent choices with active communities. Try each on a small project to get a feel for their patterns before committing to one for a larger application.
