Redux vs context API
Redux vs Context API: Redux is a centralized state container that manages data through actions and reducers in a separate store; Context API is React's built-in way to share data down the component tree without prop drilling.
Theory
TL;DR
- Context API is like a family group chat: simple, everyone sees everything, easy to set up. Redux is like a company Slack with channels and logs: structured, built for teams.
- Main difference: Context re-renders every consumer when the provider value changes; Redux re-renders only components subscribed to the specific slice of state via
useSelector. - Under 5-10 components sharing simple state? Use Context. Complex async logic, 10+ components, or team debugging needs? Use Redux.
- Redux adds boilerplate but gives you time-travel debugging via Redux DevTools; Context gives you nothing built-in.
- Modern Redux Toolkit (RTK) cuts the ceremony significantly compared to legacy Redux.
Quick Example
The core difference is in how re-renders are triggered:
// Context API - ALL consumers re-render on ANY value change
const AppContext = React.createContext();
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
return (
// One shared object - any change triggers all consumers
<AppContext.Provider value={{ theme, user }}>
<ThemeConsumer /> {/* Re-renders on theme OR user change */}
<UserConsumer /> {/* Re-renders on theme OR user change */}
</AppContext.Provider>
);
}
// Redux - only the subscribed component re-renders
function ThemeConsumer() {
const theme = useSelector(state => state.theme); // Only re-renders on theme change
return <div>{theme}</div>;
}When user changes in the Context example, ThemeConsumer re-renders too, even though it has no use for user. Redux's useSelector skips that re-render entirely.
Key Difference
Redux enforces unidirectional data flow: you dispatch an action, a pure reducer computes the next state, and only components subscribed to that slice re-render. Context just shares a value down the tree. It has no concept of actions, reducers, or selective subscriptions. Any change to the provider's value object triggers a re-render in every consumer below it. That is by design, not a bug, but it becomes a real problem as the app grows.
When to Use
- Simple theme toggle or auth status across 1-5 components: Context API.
- Form state shared between sibling components with no async logic: Context API.
- App-wide state with API calls, loading flags, and error handling: Redux with
createAsyncThunk. - 10+ components sharing state that updates frequently: Redux + Reselect.
- Team project with debugging and code review needs: Redux (the action log alone is worth it).
- Performance-sensitive lists with frequent updates: Redux + Reselect.
Comparison Table
| Feature | Context API | Redux (RTK) |
|---|---|---|
| Setup | createContext() + Provider, zero extra deps | @reduxjs/toolkit + configureStore() |
| Boilerplate | Minimal | Medium (slices, actions) |
| Re-renders | All consumers on value change | Only matching useSelector calls |
| DevTools | None built-in | Time-travel, action log (Redux DevTools) |
| Async handling | Manual (useEffect + local state) | createAsyncThunk built-in |
| Scalability | Small to medium apps | Large apps, teams |
| When to use | Themes, auth token, locale | E-commerce cart, dashboard with API sync |
How It Works Internally
React Context uses the fiber tree: the Provider writes to context._currentValue, and every consumer walks up the fiber stack to find the nearest Provider. Any change to the provider's value object fails a shallow equality check and triggers scheduleUpdateOnFiber for all consumers below.
Redux stores state in a plain JS object (wrapped with Immer in RTK for safe mutations in place). When you dispatch an action, the store runs the reducers, then notifies subscribers. useSelector compares previous and next selected values with strict equality - if equal, the component skips re-render. In React 18, Redux batches updates using unstable_batchedUpdates, so multiple dispatches in the same event handler do not cause multiple render cycles.
Common Mistakes
Putting all state in one Context object:
// Wrong - new object reference on every render
<Context.Provider value={{ theme, user, cart }}>
// Fix - memoize the value
const value = useMemo(() => ({ theme, user }), [theme, user]);
<Context.Provider value={value}>A new object literal is created on every render, so shallow equality always fails and all consumers re-render, even when nothing relevant changed.
Using Context for high-frequency updates:
// Wrong - 60fps typing re-renders the entire consumer subtree
const [searchQuery, setSearchQuery] = useState('');
<SearchContext.Provider value={{ searchQuery, setSearchQuery }}>Keep high-frequency state local. Put only the settled final value in Context if other components need it.
Selecting too broadly in Redux:
// Wrong - re-renders on any store change at all
const todos = useSelector(state => state.todos);
// Fix - select the exact slice you need
const todoList = useSelector(state => state.todos.list);Mutating state directly in a plain Redux reducer:
// Wrong - breaks Redux DevTools and state predictability
reducer: (state, action) => {
state.arr.push(action.payload); // Direct mutation
return state;
}
// Fix with RTK - Immer handles this safely
reducers: {
addItem: (state, action) => {
state.arr.push(action.payload); // RTK/Immer converts this to an immutable update
}
}In plain Redux without RTK, always return a new object. With RTK, the Immer draft lets you write mutation-style code that produces an immutable result.
Real-World Usage
- React docs:
ThemeContextfor simple theming across a component tree. - Next.js apps: auth context for sharing session and user object.
- Shopify Polaris: Redux for centralized merchant state across admin panels.
- VS Code extensions: Redux for workspace state shared across editor panels.
- RTK Query in larger apps: API caching for posts and comments with automatic cache invalidation.
I saw this pattern play out on a mid-size product: we started with Context for auth, then added it for cart, then for notifications. By the time we had 4 nested contexts, the component tree was unreadable and debugging was guesswork. We migrated to Redux, and the action log alone justified the effort.
Follow-Up Questions
Q: How does Context cause unnecessary re-renders, and what are the fixes?
A: Any change to the provider's value object triggers re-renders in all consumers, because object literals fail shallow equality checks on every render. Fixes: memoize the value with useMemo, split into multiple Contexts by update frequency, or switch to a library like Zustand for that piece of state.
Q: Walk through the Redux flow: action, thunk, reducer, component.
A: You dispatch an action (a plain object with a type field). If async, a thunk middleware intercepts it, does the async work, then dispatches a regular action. The reducer receives it and returns new state. Components with useSelector check if their selected slice changed - if yes, they re-render.
Q: Why pick Redux over Zustand or Jotai for a large app?
A: Redux DevTools with time-travel debugging, a mature middleware ecosystem (RTK Query, redux-saga), and well-established patterns for teams. Zustand is lighter and works well for medium apps, but lacks the DevTools depth and predictability guarantees that Redux provides at scale.
Q: Performance: Context vs Redux with 1000 items?
A: Redux with Reselect normalizes lookups to roughly O(1) via memoized selectors. Context triggers an O(n) fiber tree traversal on every value change. For large lists with frequent updates, the difference becomes visible in profiling.
Q: In React 18 concurrent mode, how does Redux batching interact with startTransition?
A: Redux uses unstable_batchedUpdates to batch dispatch calls. With concurrent mode, startTransition marks updates as non-urgent, so the UI stays responsive during heavy selector computation. For async thunks that need to show UI feedback like a loading spinner, pair useTransition with the dispatch so the spinner renders immediately while the data loads in the background.
Examples
Theme Toggle with Context API
const ThemeContext = React.createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// Memoize to prevent re-renders from new object references
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
function ThemeButton() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}Theme changes rarely, consumer count is small, and there is no async logic. This is the right fit for Context.
Todos with API (Redux Toolkit)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk('todos/fetch', async () => {
const res = await fetch('/api/todos');
return res.json();
});
const todosSlice = createSlice({
name: 'todos',
initialState: { list: [], filter: 'all', loading: false },
reducers: {
setFilter: (state, action) => {
state.filter = action.payload; // Immer handles immutability
}
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => { state.loading = true; })
.addCase(fetchTodos.fulfilled, (state, action) => {
state.list = action.payload;
state.loading = false;
});
}
});
// Component subscribes only to what it needs
function TodoList() {
const list = useSelector(state => state.todos.list); // Ignores filter changes
const dispatch = useDispatch();
useEffect(() => { dispatch(fetchTodos()); }, [dispatch]);
return <ul>{list.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>;
}TodoList re-renders only when todos.list changes. Changing the filter does not touch it. With Context, both components would share the same value object and both would re-render on every state update.
Object References and Memoization Edge Case
// Context - new object on every render breaks React.memo
function BadProvider({ children }) {
const [count, setCount] = useState(0);
return (
// New object every render - React.memo on Child won't prevent re-renders
<Context.Provider value={{ count, increment: () => setCount(c => c + 1) }}>
<Child />
</Context.Provider>
);
}
// Redux - Immer produces a new reference only when data actually changed
const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0 },
reducers: {
increment: state => { state.count += 1; } // Only count ref changes
}
});
// MemoizedChild skips re-render when unrelated slices update
const MemoizedChild = React.memo(function Child() {
const count = useSelector(state => state.counter.count);
return <div>{count}</div>;
});Immer produces a new object reference only when data actually changed. Combined with a narrow selector, React.memo behaves the way you'd expect it to.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.