Skip to main content

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:

jsx
// 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

FeatureContext APIRedux (RTK)
SetupcreateContext() + Provider, zero extra deps@reduxjs/toolkit + configureStore()
BoilerplateMinimalMedium (slices, actions)
Re-rendersAll consumers on value changeOnly matching useSelector calls
DevToolsNone built-inTime-travel, action log (Redux DevTools)
Async handlingManual (useEffect + local state)createAsyncThunk built-in
ScalabilitySmall to medium appsLarge apps, teams
When to useThemes, auth token, localeE-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:

jsx
// 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:

jsx
// 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:

jsx
// 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:

jsx
// 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: ThemeContext for 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

jsx
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)

jsx
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

jsx
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?