The answer interviewers want: Context and Redux solve different problems, so “versus” is slightly misleading. Context is a transport mechanism. It lets a component deep in the tree read a value without passing props through every level. It doesn’t store anything itself: the state still lives in useState or useReducer in some parent. Redux (written today with Redux Toolkit) is a state management library. It keeps state outside React in a single store, changes it only through dispatched actions handled by reducers, and lets each component subscribe to just the slice it needs.
So use Context for values that change rarely and are read widely: theme, locale, the current user, a feature-flag client. Reach for Redux Toolkit, or a lighter store, when a lot of client state changes often, many components read different parts of it, and you want predictable updates you can trace.
Context and Redux side by side
Context (+ useState/useReducer) |
Redux Toolkit | Zustand-style store | |
|---|---|---|---|
| What it is | Dependency injection for a value | Centralised store with actions and reducers | Small external store with hooks |
| Install | Built into React | @reduxjs/toolkit and react-redux |
One small package |
| Re-render behaviour | All consumers re-render when the value changes | Only components whose selected value changed | Only components whose selected value changed |
| Update model | Whatever the provider exposes | dispatch(action), reducers produce the next state |
Call functions on the store, set() merges state |
| Debugging | React DevTools | Redux DevTools: action log, state diffs, time travel | Optional devtools middleware |
| Async and server data | Your own code | createAsyncThunk, plus RTK Query for caching |
Your own code, or pair with a data library |
| Works outside components | No | Yes, store.getState() / store.dispatch() |
Yes |
| Best fit | Rarely changing, widely read values | Large, frequently changing, shared client state | Moderate shared state, minimal ceremony |
Why re-renders are the real difference
When a provider’s value changes (by Object.is comparison), React re-renders every component that calls useContext on that context, below that provider. There’s no way for a consumer to say “I only care about theme”. Redux’s useSelector works differently: each component passes a selector, react-redux runs it after every dispatch, and the component re-renders only if the selected result changed.
React itself isn’t installed in my scratch environment, so here’s a dependency-free Node 22 simulation of both subscription models. It uses a minimal Redux-style store and counts how many times each “component” would re-render.
function createStore(reducer, initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state,
dispatch(action) {
state = reducer(state, action);
listeners.forEach((l) => l());
},
subscribe(l) { listeners.add(l); return () => listeners.delete(l); },
};
}
function reducer(state, action) {
switch (action.type) {
case 'cart/added':
return { ...state, cart: [...state.cart, action.item] };
case 'ui/themeToggled':
return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' };
default:
return state;
}
}
const store = createStore(reducer, { theme: 'light', cart: [] });
const renders = { contextHeader: 0, contextCart: 0, selectorHeader: 0, selectorCart: 0 };
// Context-style: every consumer re-runs whenever the provided value is a new object.
let lastValue = store.getState();
store.subscribe(() => {
const value = store.getState();
if (!Object.is(lastValue, value)) { lastValue = value; renders.contextHeader++; renders.contextCart++; }
});
// Selector-style: a consumer re-runs only if its selected slice changed.
function useSelectorSim(select, name) {
let prev = select(store.getState());
store.subscribe(() => {
const next = select(store.getState());
if (!Object.is(prev, next)) { prev = next; renders[name]++; }
});
}
useSelectorSim((s) => s.theme, 'selectorHeader');
useSelectorSim((s) => s.cart, 'selectorCart');
store.dispatch({ type: 'cart/added', item: 'book' });
store.dispatch({ type: 'cart/added', item: 'pen' });
store.dispatch({ type: 'ui/themeToggled' });
store.dispatch({ type: 'unknown/action' });
console.log(store.getState());
console.log(renders);{ theme: 'dark', cart: [ 'book', 'pen' ] }
{
contextHeader: 3,
contextCart: 3,
selectorHeader: 1,
selectorCart: 2
}Four dispatches, three of which produced a new state object (the unknown action returned the same object, so nobody re-rendered). With one context holding everything, the header re-rendered three times even though the theme changed once, and the cart re-rendered for the theme toggle it doesn’t care about. With selectors, each component re-rendered exactly when its own data changed. In a small app this doesn’t matter. In a dashboard where a frequently changing value sits next to a big component tree, it’s the difference between smooth and sluggish.
The simulation also shows why reducers must return new objects instead of mutating: both models detect changes by reference. Mutate state.cart.push(...) and return the same object, and nothing re-renders.
What each looks like in a React app
The snippets below follow the current React and Redux Toolkit APIs but weren’t executed here; treat them as illustrative.
A context for the theme is a provider, a value and a hook:
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>
<Page />
</ThemeContext.Provider>
);
}
function ThemeButton() {
const { theme, setTheme } = useContext(ThemeContext);
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}The useMemo matters. Without it, { theme, setTheme } is a new object on every App render, and every consumer re-renders even when the theme didn’t change. (React 19 also lets you render <ThemeContext value={value}> directly, without .Provider.)
The same kind of state in Redux Toolkit uses a slice:
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
added(state, action) { state.items.push(action.payload); },
},
});
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
export const { added } = cartSlice.actions;function CartCount() {
const count = useSelector((s) => s.cart.items.length);
return <span>{count}</span>;
}state.items.push looks like mutation, but createSlice runs reducers through Immer, which records the change and produces a new immutable state. That’s one of the main reasons “Redux has too much boilerplate” is an outdated complaint. Hand-written action type constants, switch statements and spread-heavy reducers belong to pre-Toolkit Redux, and the Redux docs now recommend Toolkit for all new code.
Where Zustand-style stores fit
Between “a couple of contexts” and “Redux with slices and middleware” sits a set of small store libraries. Zustand is the best known: you create a store with state and functions that call set, and components read it through a hook with a selector, like useCartStore((s) => s.items.length). Under the hood React’s useSyncExternalStore handles the subscription, which is also what react-redux uses today. You get Redux’s selective re-renders without actions, reducers or a provider. What you give up is Redux’s enforced structure, its mature devtools workflow and RTK Query. For a mid-sized app with a few pieces of shared client state, that’s often a good trade.
Misconceptions to avoid in the interview
“Context replaces Redux.” It replaces Redux only in apps that used Redux to avoid prop drilling. If you need selective subscriptions, middleware, or a traceable action history, Context alone doesn’t provide them.
“Redux is for server data.” Fetched data (lists, details, pagination) has caching, refetching and staleness problems that are better handled by a data-fetching layer: RTK Query if you’re on Redux, TanStack Query otherwise, or your framework’s server data loading. Putting raw API responses into hand-written slices is a common source of bugs.
“Splitting contexts is premature.” If one value changes often and another rarely, separate providers are the standard fix, and context splitting shows how. Splitting state from setter functions is another cheap win, since setters never change.
“Put everything in the global store.” Form input that only one component uses belongs in local state. A global store is for state that several distant components share.
Interview practice: pick an approach
You’re building a trading dashboard. It has a dark-mode toggle, the logged-in user, a watchlist that users edit, and live prices that update several times per second for 200 symbols, each shown in its own row. What would you use for each, and why?
Model answer
Theme and current user: Context. They change rarely and are read in many places, so re-rendering consumers on change is cheap and acceptable. The watchlist: shared client state that several screens edit, so a store fits, either a Redux Toolkit slice (if the team already uses Redux or wants its devtools) or a small Zustand store. Live prices are where Context would hurt. If 200 rows read one context, each tick would re-render all of them. Put prices in a store and have each row select only its own symbol, like useSelector((s) => s.prices[symbol]), so a tick re-renders only the rows whose price moved. Mention that the selector must return a stable value; returning a new object each time would re-render every row anyway. Finally, if the initial watchlist and price history come from an API, I’d load them with RTK Query or a similar data layer instead of a hand-written slice.