React State Management: Redux vs Context API
You add a logged-in user to App, and a week later that user prop is being passed through five components that never read it, just so an avatar in the header can show a name. Someone suggests Redux. Someone else says "just use Context." Both are reasonable answers, and picking the wrong one usually shows up later as either a pile of boilerplate or a page that re-renders on every keystroke.
This post compares Redux and the Context API using the same auth example in both, then looks at re-render behavior, a decision checklist, and the mistakes I see most often with each.
- How Context and Redux actually move data to components
- Why one re-renders more than the other
- A working auth implementation in each
- A quick way to decide between them
- Pitfalls and the error messages they produce
Understanding the State Management Problem
React data flows one way: down through props. That's fine for a handful of components. Once the tree gets deeper, a few problems start showing up:
Prop drilling vs centralized state management: Components access state directly without intermediate props
Common State Management Challenges
- Prop Drilling: Passing props through multiple component levels that don't need them
- Shared State: Multiple components needing access to the same data
- State Synchronization: Keeping related state consistent across components
- Performance: Avoiding unnecessary re-renders when state changes
React Context API: Built-in Solution
Context ships with React, so there's nothing to install. The current API arrived in React 16.3, and once useContext landed with hooks in 16.8 it got much nicer to consume. Strictly speaking, Context isn't a state manager at all. It's a way to hand a value down the tree. The state itself still lives in useState or useReducer.
What It Provides
A way to pass data through the component tree without manually passing props at every level.
Best For
Theme data, user authentication, language preferences, and infrequently changing global state.
Context API Architecture
Context provides direct access to state for any nested component that needs it
Implementation: Context API with useReducer
For anything beyond a single value, I pair Context with useReducer. Here's an auth context built that way:
import { createContext, useContext, useReducer, useMemo } from 'react';
// Initial state
const initialState = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
};
// Action types
const ActionTypes = {
LOGIN_START: 'LOGIN_START',
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
LOGIN_FAILURE: 'LOGIN_FAILURE',
LOGOUT: 'LOGOUT',
UPDATE_USER: 'UPDATE_USER',
};
// Reducer function
function userReducer(state, action) {
switch (action.type) {
case ActionTypes.LOGIN_START:
return { ...state, isLoading: true, error: null };
case ActionTypes.LOGIN_SUCCESS:
return {
...state,
user: action.payload,
isAuthenticated: true,
isLoading: false,
error: null,
};
case ActionTypes.LOGIN_FAILURE:
return {
...state,
user: null,
isAuthenticated: false,
isLoading: false,
error: action.payload,
};
case ActionTypes.LOGOUT:
return { ...initialState };
case ActionTypes.UPDATE_USER:
return { ...state, user: { ...state.user, ...action.payload } };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
// Create contexts
const UserStateContext = createContext(null);
const UserDispatchContext = createContext(null);
// Provider component
export function UserProvider({ children }) {
const [state, dispatch] = useReducer(userReducer, initialState);
// Memoize to prevent unnecessary re-renders
const stateValue = useMemo(() => state, [state]);
const dispatchValue = useMemo(() => dispatch, []);
return (
<UserStateContext.Provider value={stateValue}>
<UserDispatchContext.Provider value={dispatchValue}>
{children}
</UserDispatchContext.Provider>
</UserStateContext.Provider>
);
}
// Custom hooks for consuming context
export function useUserState() {
const context = useContext(UserStateContext);
if (context === null) {
throw new Error('useUserState must be used within a UserProvider');
}
return context;
}
export function useUserDispatch() {
const context = useContext(UserDispatchContext);
if (context === null) {
throw new Error('useUserDispatch must be used within a UserProvider');
}
return context;
}
// Convenience hook for both
export function useUser() {
return [useUserState(), useUserDispatch()];
}
// Action creators
export const userActions = {
loginStart: () => ({ type: ActionTypes.LOGIN_START }),
loginSuccess: (user) => ({ type: ActionTypes.LOGIN_SUCCESS, payload: user }),
loginFailure: (error) => ({ type: ActionTypes.LOGIN_FAILURE, payload: error }),
logout: () => ({ type: ActionTypes.LOGOUT }),
updateUser: (data) => ({ type: ActionTypes.UPDATE_USER, payload: data }),
};
// App.jsx - Wrap your app with the provider
import { UserProvider } from './context/UserContext';
function App() {
return (
<UserProvider>
<Router>
<Header />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Router>
</UserProvider>
);
}
// Header.jsx - Consuming the context
import { useUserState } from './context/UserContext';
function Header() {
const { user, isAuthenticated } = useUserState();
return (
<header>
<nav>
{isAuthenticated ? (
<span>Welcome, {user.name}!</span>
) : (
<LoginButton />
)}
</nav>
</header>
);
}
// LoginForm.jsx - Dispatching actions
import { useUserDispatch, userActions } from './context/UserContext';
function LoginForm() {
const dispatch = useUserDispatch();
const handleLogin = async (credentials) => {
dispatch(userActions.loginStart());
try {
const response = await api.login(credentials);
dispatch(userActions.loginSuccess(response.user));
} catch (error) {
dispatch(userActions.loginFailure(error.message));
}
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleLogin(formData); }}>
{/* form fields */}
</form>
);
}
State and dispatch live in separate contexts on purpose. dispatch from useReducer never changes identity, so a component like LoginForm that only dispatches won't re-render when the user object changes.
Redux: A Store Outside React
Redux has been around since 2015, and most of its bad reputation comes from the early days of hand-written action types and switch statements. Redux Toolkit (RTK) removed most of that. The core idea hasn't changed: state lives in one store outside the component tree, and components subscribe to just the pieces they need.
Single Source of Truth
All application state lives in one store, making debugging and state inspection straightforward.
Time Travel Debugging
Redux DevTools let you step back and forth through every action and see the state diff for each one.
Middleware Support
Middleware sits between dispatch and the reducer, which is where async logic, logging, and analytics hooks go.
Predictable Updates
State can only change through actions, making updates traceable and testable.
Redux Architecture
Every change goes through the same loop, so you can always trace which action produced which state
Implementation: Redux Toolkit
RTK is what the Redux docs tell you to use now. createSlice generates action creators for you, Immer lets you write "mutating" code that produces immutable updates, and configureStore wires up thunks and DevTools without extra setup. Here's the same auth logic:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { api } from '../services/api';
// Async thunk for login
export const loginUser = createAsyncThunk(
'user/login',
async (credentials, { rejectWithValue }) => {
try {
const response = await api.login(credentials);
// Store token in localStorage
localStorage.setItem('token', response.token);
return response.user;
} catch (error) {
return rejectWithValue(error.response?.data?.message || 'Login failed');
}
}
);
// Async thunk for fetching user profile
export const fetchUserProfile = createAsyncThunk(
'user/fetchProfile',
async (_, { rejectWithValue }) => {
try {
const response = await api.getProfile();
return response.user;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const initialState = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
logout: (state) => {
localStorage.removeItem('token');
state.user = null;
state.isAuthenticated = false;
state.error = null;
},
updateUser: (state, action) => {
state.user = { ...state.user, ...action.payload };
},
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
// Login
.addCase(loginUser.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.isLoading = false;
state.user = action.payload;
state.isAuthenticated = true;
})
.addCase(loginUser.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload;
})
// Fetch profile
.addCase(fetchUserProfile.pending, (state) => {
state.isLoading = true;
})
.addCase(fetchUserProfile.fulfilled, (state, action) => {
state.isLoading = false;
state.user = action.payload;
state.isAuthenticated = true;
})
.addCase(fetchUserProfile.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload;
});
},
});
export const { logout, updateUser, clearError } = userSlice.actions;
// Selectors
export const selectUser = (state) => state.user.user;
export const selectIsAuthenticated = (state) => state.user.isAuthenticated;
export const selectIsLoading = (state) => state.user.isLoading;
export const selectError = (state) => state.user.error;
export default userSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import cartReducer from './cartSlice';
import notificationReducer from './notificationSlice';
export const store = configureStore({
reducer: {
user: userReducer,
cart: cartReducer,
notifications: notificationReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
// Ignore these action types
ignoredActions: ['persist/PERSIST'],
},
}),
devTools: process.env.NODE_ENV !== 'production',
});
// TypeScript types (if using TS)
// export type RootState = ReturnType<typeof store.getState>;
// export type AppDispatch = typeof store.dispatch;
// App.jsx
import { Provider } from 'react-redux';
import { store } from './store';
function App() {
return (
<Provider store={store}>
<Router>
<AppContent />
</Router>
</Provider>
);
}
// Header.jsx - Reading state with useSelector
import { useSelector } from 'react-redux';
import { selectUser, selectIsAuthenticated } from './store/userSlice';
function Header() {
const user = useSelector(selectUser);
const isAuthenticated = useSelector(selectIsAuthenticated);
return (
<header>
{isAuthenticated ? (
<UserMenu user={user} />
) : (
<LoginButton />
)}
</header>
);
}
// LoginForm.jsx - Dispatching actions
import { useDispatch, useSelector } from 'react-redux';
import { loginUser, selectIsLoading, selectError, clearError } from './store/userSlice';
function LoginForm() {
const dispatch = useDispatch();
const isLoading = useSelector(selectIsLoading);
const error = useSelector(selectError);
const [formData, setFormData] = useState({ email: '', password: '' });
const handleSubmit = (e) => {
e.preventDefault();
dispatch(loginUser(formData));
};
useEffect(() => {
// Clear error when component unmounts
return () => dispatch(clearError());
}, [dispatch]);
return (
<form onSubmit={handleSubmit}>
{error && <div className="error">{error}</div>}
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
disabled={isLoading}
/>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
Head-to-Head Comparison
Side by side, the differences look like this:
| Feature | Context API | Redux |
|---|---|---|
| Setup Complexity | Minimal, built into React | Moderate, needs @reduxjs/toolkit and react-redux |
| Bundle Size | 0 KB (part of React) | ~10-15 KB (with RTK) |
| Learning Curve | Low, plain React patterns | Medium: slices, thunks, selectors |
| DevTools | React DevTools only | Redux DevTools with action log and time travel |
| Middleware | Not built-in | Built-in middleware pipeline |
| Async Operations | Manual implementation | Built-in with createAsyncThunk |
| Re-render Control | All consumers re-render | Fine-grained with selectors |
| State Structure | Flexible, multiple contexts | Single store, organized by slices |
| Testing | Requires wrapper setup | Easy with isolated reducers |
| TypeScript Support | Good | Excellent |
Performance Comparison
This is where the two really differ, and it's usually what pushes a project from one to the other.
Context API Performance Characteristics
When the context value changes, every consumer re-renders, even one that only reads isLoading while you changed user.name. There's no built-in way to subscribe to part of a context. You can split contexts or wrap children in memo, but that's work you do by hand.
Redux Performance Characteristics
Components only re-render when their selected state changes. useSelector compares the selected value with === after each dispatch and skips the render if it's the same. For derived data, createSelector memoizes the result so you don't return a fresh object every time.
import { createSelector } from '@reduxjs/toolkit';
// Basic selectors
const selectCartItems = (state) => state.cart.items;
const selectTaxRate = (state) => state.settings.taxRate;
// Memoized selector - only recalculates when dependencies change
export const selectCartTotal = createSelector(
[selectCartItems, selectTaxRate],
(items, taxRate) => {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * taxRate;
return {
subtotal,
tax,
total: subtotal + tax,
itemCount: items.reduce((count, item) => count + item.quantity, 0),
};
}
);
// Usage - component only re-renders when cart total actually changes
function CartSummary() {
const { subtotal, tax, total, itemCount } = useSelector(selectCartTotal);
// This component won't re-render for unrelated state changes
}
Decision Framework: Which Should You Choose?
I run through these questions in roughly this order:
Practical Recommendations
Use Context API When:
- State changes infrequently (theme, locale)
- Data is needed by many components
- You want minimal setup
- Your team is new to React
- Building a small to medium app
- Prototyping or MVP stage
Use Redux When:
- Complex state with many actions
- State updates frequently
- You need time-travel debugging
- Multiple developers on the team
- Complex async operations
- State needs to be serializable
You don't have to pick one. Plenty of apps use both:
- Context for theme, authentication, and UI preferences
- Redux for complex domain state like shopping carts, user data, and API caches
Context Performance: Avoiding Unnecessary Re-renders
This is where most Context setups go wrong. When the context value changes, every component that consumes it re-renders, even if the piece it actually uses is the same as before. Three things help.
Strategy 1: Split Contexts by Update Frequency
Don't keep state that changes constantly in the same context as state that almost never changes. A consumer only re-renders when its own context changes:
// BAD: one context for everything, so all consumers re-render on any change
const AppContext = createContext({ user, theme, cart, notifications });
// GOOD: separate contexts, so components only subscribe to what they use
const UserContext = createContext(null); // Changes rarely
const ThemeContext = createContext(null); // Changes on user toggle
const CartContext = createContext(null); // Changes frequently
const NotificationsContext = createContext(null); // Changes on events
Strategy 2: Memoize the Context Value
When the Provider component re-renders for any reason, an inline value={{ ... }} object is brand new, so React treats it as changed and re-renders every consumer. useMemo keeps the same object until its dependencies change:
import { createContext, useContext, useState, useMemo } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
const addItem = (product) =>
setItems(prev => [...prev, product]);
const removeItem = (id) =>
setItems(prev => prev.filter(item => item.id !== id));
const total = items.reduce((sum, item) => sum + item.price, 0);
// Without useMemo, { items, addItem, removeItem, total } is a new
// object reference on every render, re-rendering all consumers.
const value = useMemo(
() => ({ items, addItem, removeItem, total }),
[items] // Only recreate when items actually changes
);
return (
<CartContext.Provider value={value}>
{children}
</CartContext.Provider>
);
}
export const useCart = () => useContext(CartContext);
Strategy 3: Separate State and Dispatch
With useReducer, you can put state and dispatch in two different contexts. dispatch is stable across renders, so components that only send actions (buttons, mostly) stop re-rendering when the state changes:
import { createContext, useContext, useReducer } from 'react';
const CountStateContext = createContext(null);
const CountDispatchContext = createContext(null);
function countReducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: throw new Error(`Unknown action: ${action.type}`);
}
}
export function CountProvider({ children }) {
const [state, dispatch] = useReducer(countReducer, { count: 0 });
return (
<CountStateContext.Provider value={state}>
<CountDispatchContext.Provider value={dispatch}>
{children}
</CountDispatchContext.Provider>
</CountStateContext.Provider>
);
}
// Components that display count: re-render when count changes
export const useCountState = () => useContext(CountStateContext);
// Components that only dispatch: never re-render due to count changes
export const useCountDispatch = () => useContext(CountDispatchContext);
// Usage in a display component:
function Counter() {
const { count } = useCountState();
return <h1>Count: {count}</h1>;
}
// Usage in a button component: won't re-render when count changes!
function IncrementButton() {
const dispatch = useCountDispatch();
return <button onClick={() => dispatch({ type: 'increment' })}>+</button>;
}
Composing Multiple Providers
Once you have four or five contexts, the nesting in main.jsx gets ugly. I move it into one AppProviders component:
import { AuthProvider } from '../context/AuthContext';
import { ThemeProvider } from '../context/ThemeContext';
import { CartProvider } from '../context/CartContext';
import { NotificationsProvider } from '../context/NotificationsContext';
// Compose providers into a single wrapper. Order matters!
// Outer providers are available to inner providers.
export function AppProviders({ children }) {
return (
<ThemeProvider>
<AuthProvider>
<NotificationsProvider>
<CartProvider>
{children}
</CartProvider>
</NotificationsProvider>
</AuthProvider>
</ThemeProvider>
);
}
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AppProviders } from './providers/AppProviders';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AppProviders>
<App />
</AppProviders>
</BrowserRouter>
</React.StrictMode>
);
Best Practices for Both Approaches
Context API Best Practices
Split Context by Domain
Create separate contexts for unrelated data (UserContext, ThemeContext, SettingsContext) to prevent unnecessary re-renders.
Separate State and Dispatch
Use two contexts (one for state, one for dispatch) so components that only dispatch don't re-render on state changes.
Memoize Context Values
If you pass an object like { state, dispatch }, wrap it in useMemo. Otherwise it's a new reference on every provider render and every consumer re-renders with it.
Create Custom Hooks
Wrap useContext in hooks like useUser() that throw when there's no provider. Without that check you get null back and a confusing "Cannot read properties of null" crash somewhere far from the real cause.
Redux Best Practices
Use Redux Toolkit
Don't start a new project with hand-written Redux. RTK includes Immer for immutable updates, configureStore with sane defaults, and createSlice for less boilerplate.
Normalize State Shape
Store data in a flat, normalized structure with IDs for efficient lookups and updates.
Use Selectors
Read state through named selectors so a change in state shape only touches one file. Use createSelector for computed values.
Keep Reducers Pure
No side effects in reducers. Use middleware (thunks, sagas) for async operations.
Common Pitfalls to Avoid
- Putting everything in one context: every consumer re-renders on any change, so a modal toggle re-renders your whole header.
- Creating the context value inline:
value={{ user, setUser }}is a new object every render. - Relying on the default value: a component rendered outside the provider silently gets the
createContextdefault instead of an error. This bites in tests and Storybook. - Using it for state that changes constantly: form inputs or mouse position in context means re-rendering every consumer on every event.
- Mutating state outside createSlice: Immer only protects you inside slice reducers. Mutating state in a selector or component still causes stale UI.
- Storing non-serializable values: Dates, Promises, or class instances in state trigger the console warning "A non-serializable value was detected in the state" and confuse DevTools.
- Over-selecting:
useSelector(state => state)or returning a new object from a selector re-renders on every dispatch. - Side effects in reducers: API calls and dispatches belong in thunks or middleware.
If a test renders a component that calls useSelector without wrapping it in <Provider store={store}>, react-redux throws "could not find react-redux context value; please ensure the component is wrapped in a <Provider>". The fix is a small renderWithProviders helper that creates a fresh store per test, so state doesn't leak between tests.
When I'd Pick Each (and When I Wouldn't)
I start with Context for auth, theme, and locale. These values change a few times per session, so the "every consumer re-renders" cost doesn't matter. I'd skip Redux entirely for a small app or a marketing site with a login button. Two extra packages and a store file buy you nothing there.
I move to Redux when I notice I'm building it by hand: several reducers in different contexts, custom memo tricks to stop re-renders, or a "why is this value wrong" bug I can't trace. Redux's action log is what you want in that situation.
Before reaching for either, check whether the state is really server data. If most of your "global state" is API responses with loading and error flags (like the auth example here), a data fetching library such as RTK Query or TanStack Query handles caching and refetching better than a hand-written slice or reducer. Likewise, don't move form state into either one. Local useState is still the right default for anything only one component tree cares about.