React State Management in 2026: Context vs Zustand vs Redux Toolkit
"Should I use Context, Zustand, or Redux?" is one of the most-asked React questions. In 2026, the ecosystem has matured and the answer is clearer. Here's a comprehensive comparison.
The State Management Landscape
| Solution | Bundle Size | Complexity | Devtools | Best For |
|---|---|---|---|---|
| React Context | 0KB (built-in) | Low | Basic | Theme, auth, locale |
| Zustand | 1.1KB | Low | Chrome ext | Most apps |
| Redux Toolkit | ~11KB | Medium | Excellent | Large apps, complex flows |
| Jotai | 3.1KB | Low | Yes | Atomic state |
| TanStack Query | 13KB | Medium | Excellent | Server state |
React Context — When to Use It
Context is built-in and great for infrequently changing global data.
// ✅ Good use of Context: Theme
const ThemeContext = createContext<'light' | 'dark'>('light');
function ThemeProvider({ children }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// ✅ Good use of Context: Current user (rarely changes)
const AuthContext = createContext<{ user: User | null; logout: () => void } | null>(null);
function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
Context Performance Problem
// ❌ Problem: ANY context value change re-renders ALL consumers
const AppContext = createContext({
user: null,
cart: [], // Changes frequently
theme: 'light', // Rarely changes
notifications: [], // Changes frequently
});
// When cart updates, <ThemeToggle /> re-renders even though it doesn't use cart!
function ThemeToggle() {
const { theme, setTheme } = useContext(AppContext); // Subscribes to ALL changes
return <button onClick={() => setTheme('dark')}>{theme}</button>;
}
// ✅ Solution: Split contexts by update frequency
const ThemeContext = createContext(null); // Rarely changes
const UserContext = createContext(null); // Rarely changes
const CartContext = createContext(null); // Changes often
const NotificationContext = createContext(null); // Changes often
Use Context for: Theme, auth user, locale, feature flags — data that's global and doesn't change often.
Zustand — The Sweet Spot
Zustand is simple, small, and solves the Context re-render problem.
npm install zustand
// stores/cartStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (newItem) => set((state) => {
const existing = state.items.find(i => i.id === newItem.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === newItem.id ? { ...i, quantity: i.quantity + 1 } : i
)
};
}
return { items: [...state.items, { ...newItem, 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: [] }),
total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{ name: 'cart-storage' } // Persists to localStorage
)
);
// Using Zustand — selectors prevent unnecessary re-renders
function CartIcon() {
// Only re-renders when items.length changes
const itemCount = useCartStore(state => state.items.length);
return <span>{itemCount} items</span>;
}
function CartTotal() {
// Only re-renders when total changes
const total = useCartStore(state => state.total());
return <span>${total.toFixed(2)}</span>;
}
function AddToCartButton({ product }) {
const addItem = useCartStore(state => state.addItem);
return <button onClick={() => addItem(product)}>Add to Cart</button>;
}
Zustand with TypeScript and DevTools
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
const useStore = create<Store>()(
devtools( // Chrome DevTools support
persist( // LocalStorage persistence
(set) => ({
// ... store definition
}),
{ name: 'my-store' }
),
{ name: 'MyStore' } // DevTools display name
)
);
Redux Toolkit — For Complex Apps
Redux Toolkit (RTK) modernizes Redux with much less boilerplate.
npm install @reduxjs/toolkit react-redux
// store/slices/userSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
interface UserState {
currentUser: User | null;
users: User[];
loading: boolean;
error: string | null;
}
// Async thunk with automatic pending/fulfilled/rejected actions
export const fetchUsers = createAsyncThunk(
'users/fetchAll',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const userSlice = createSlice({
name: 'users',
initialState: {
currentUser: null,
users: [],
loading: false,
error: null,
} as UserState,
reducers: {
setCurrentUser: (state, action: PayloadAction<User>) => {
state.currentUser = action.payload; // Immer allows "mutations"
},
logout: (state) => {
state.currentUser = null;
},
updateUser: (state, action: PayloadAction<Partial<User> & { id: string }>) => {
const index = state.users.findIndex(u => u.id === action.payload.id);
if (index !== -1) {
state.users[index] = { ...state.users[index], ...action.payload };
}
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.users = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
},
});
export const { setCurrentUser, logout, updateUser } = userSlice.actions;
export default userSlice.reducer;
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';
import cartReducer from './slices/cartSlice';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
export const store = configureStore({
reducer: {
users: userReducer,
cart: cartReducer,
},
// RTK includes redux-thunk and Immer automatically
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Typed hooks (use these instead of raw hooks)
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
RTK Query — Data Fetching Built In
// store/api/usersApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const usersApi = createApi({
reducerPath: 'usersApi',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token;
if (token) headers.set('authorization', `Bearer ${token}`);
return headers;
},
}),
tagTypes: ['User'],
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: ['User'],
}),
getUserById: builder.query<User, string>({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
updateUser: builder.mutation<User, Partial<User> & { id: string }>({
query: ({ id, ...patch }) => ({ url: `/users/${id}`, method: 'PATCH', body: patch }),
invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
}),
}),
});
export const { useGetUsersQuery, useGetUserByIdQuery, useUpdateUserMutation } = usersApi;
// Usage
function UserList() {
const { data: users, isLoading, error } = useGetUsersQuery();
// Automatic caching, background refetch, loading states!
}
Decision Guide
Is it local component state?
→ useState / useReducer
Is it server/async data (API responses)?
→ TanStack Query or RTK Query (NOT Zustand/Redux)
Is it shared global state?
→ Small/medium app: Zustand
→ Large app with complex flows: Redux Toolkit
Is it UI state that rarely changes? (theme, auth, locale)
→ React Context
Summary
In 2026:
- Context: Theme, auth, locale. Simple, built-in, free. Avoid for frequently updated state.
- Zustand: Sweet spot for most apps. Tiny, simple, fast, great DX.
- Redux Toolkit: Large apps, complex async flows, need time-travel debugging.
- TanStack Query/RTK Query: Server state. Use these instead of putting API responses in Redux.
→ Inspect and format your state snapshots with JSON Viewer.