← Back to Redux
Lesson 7 · State Management Fundamentals

Context API vs Redux

React Context delivers a value to components; it does not manage state. See - with counted re-renders - why one big context re-renders every consumer, how to fix it, and when Context or Redux is the right choice.

Beginner40 min

What you will be able to do

  • Explain what React Context does - and what it does not do
  • Use createContext, a Provider and useContext, and avoid the missing-Provider crash
  • Measure why one big context re-renders every consumer when any part changes
  • Fix the two common Context performance problems: a new value object every render, and one context for everything
  • Build a small "mini Redux" with Context and useReducer - and name what it is missing
  • Use Context for what it is best at: values that rarely change, and injecting services
  • Choose between Context and Redux for a given piece of state

The idea, in plain English

Lesson 5 showed Context as one of the ways out of prop drilling. Many developers then decide that Context "replaces Redux". This lesson shows why that is only half true - and the half that is false matters.

React Context is a way to deliver a value to every component below a Provider, without passing props through each level. That is all it does. It does not store the value, it does not decide how the value changes, and it does not let a component pick only the part it needs. The state itself still lives in useState or useReducer in some component; Context is the pipe that carries it down. That is why the outline calls Context dependency injection, not a state manager.

Redux is a state manager. It holds the state, decides how it may change (actions and reducers), records every change, and lets each component subscribe to exactly the piece it reads. The two tools solve different problems, and they are often used together.

All code below was run with React 19.3, react-redux 9.3.0 and Redux Toolkit 2.13.0, rendered and clicked in a test browser (jsdom). We counted every render.

Worked example: A context change re-rendering half the app: adding to the cart re-renders the user menu and the theme label.

request flowOne click, three waysstep 1 / 4

1 - Click "Cart"

The cart changes from 0 items to 1. Only CartButton shows the cart. In a perfect world, only CartButton would re-render.

changed
cart
readers of cart
CartButton
ideal renders
1
button shows
Cart (1)

The same page - a user menu, a theme label, a cart button, a footer - built three ways. We clicked "Cart" once and counted who re-rendered.

An everyday picture: the power socket and the power station

A power socket in your wall delivers electricity to whatever you plug in. It does not make electricity. Somewhere far away, a power station makes it, decides how much to make, and keeps records. The socket only makes it easy to reach.

React Context is the socket. Any component below a Provider can "plug in" with useContext and receive the value. But the value is made and changed somewhere else - by useState or useReducer in a component. Redux is more like the power station with a meter on every house: it makes and controls the state, records every change, and each house is billed only for what it uses.

And the socket has one more limit: when the supply changes, every device plugged into it notices. That is the performance problem you will measure below.

How Context works, in three parts

createContext makes a context object, with a default value. A Provider, placed high in the tree, gives the context a value. Any component below it calls useContext and receives that value - no props in between.

If you forget the Provider, useContext quietly returns the default value. With a default of null, the component then crashes when it reads a field. We ran it: TypeError: Cannot read properties of null (reading ‘name’). A common habit is to write a small hook that throws a clear error when the Provider is missing.

A missing Provider - the default value is used
import { createContext, useContext } from "react"; const UserContext = createContext(null); // 1. create it (default: null) function UserMenu() { const user = useContext(UserContext); // 3. read it - no Provider above: null return <span>{user.name}</span>; } export default function App() { return <UserMenu />; // 2. forgot <UserContext.Provider value={...}> } // Result: // TypeError: Cannot read properties of null (reading 'name')
A safer hook
function useUser() { const user = useContext(UserContext); if (user === null) { throw new Error("useUser must be used inside <UserContext.Provider>"); } return user; }

Example 1: one big context re-renders half the app

This is the example from the course outline. It is also what most first Context setups look like: one AppContext holding everything the app shares - the user, the theme, the cart, and a function to change the cart.

To make the test fair, every child is wrapped in memo. memo stops a component re-rendering just because its parent did. So if a child re-renders here, it is because the context changed.

We clicked "Cart" once. CartButton re-rendered - correct. But UserMenu and ThemeLabel also re-rendered, although the user and the theme did not change. The reason: useContext subscribes a component to the whole context value. When any part of it changes, every component that reads the context re-renders. There is no way to say "I only care about theme". Footer, which does not read the context, stayed at 0.

OneBig.jsx
import { createContext, memo, useContext, useState } from "react"; const AppContext = createContext(null); // memo: these components do not re-render because their PARENT re-renders. // If they re-render, it is because the context changed. const UserMenu = memo(function UserMenu() { const { user } = useContext(AppContext); return <span>{user.name}</span>; }); const ThemeLabel = memo(function ThemeLabel() { const { theme } = useContext(AppContext); return <span>{theme}</span>; }); const CartButton = memo(function CartButton() { const { cart, addToCart } = useContext(AppContext); return <button onClick={() => addToCart("Redux")}>Cart ({cart.length})</button>; }); const Footer = memo(function Footer() { return <footer>© 2026</footer>; // does not read the context }); export default function App() { const [user] = useState({ name: "Ravi" }); const [theme] = useState("dark"); const [cart, setCart] = useState([]); const addToCart = (item) => setCart([...cart, item]); return ( <AppContext.Provider value={{ user, theme, cart, addToCart }}> <UserMenu /> <ThemeLabel /> <CartButton /> <Footer /> </AppContext.Provider> ); }
Renders after one click on "Cart" - measured
App 1 UserMenu 1 <- does not use the cart ThemeLabel 1 <- does not use the cart CartButton 1 Footer 0 (does not read the context)

Problem 2: a new value object on every render

There is a second, quieter problem. value={{ theme }} creates a brand-new object every time the Provider’s component renders. React compares the old and new value by identity - is it the same object? A new object is never the same, so every consumer re-renders, even if theme did not change.

We tested it with a button that changes an unrelated counter in App. Three clicks: ThemeLabel re-rendered 3 times. Then we wrapped the value in useMemo, so the same object is reused until theme really changes. Three clicks: ThemeLabel re-rendered 0 times.

NewObject.jsx - two versions of the same Provider
import { createContext, memo, useContext, useMemo, useState } from "react"; const ThemeContext = createContext(null); const ThemeLabel = memo(function ThemeLabel() { const { theme } = useContext(ThemeContext); return <span>{theme}</span>; }); export function AppNewObject() { const [theme] = useState("dark"); const [clicks, setClicks] = useState(0); // unrelated to the theme return ( <ThemeContext.Provider value={{ theme }}> {/* a NEW object every render */} <button onClick={() => setClicks(clicks + 1)}>Clicked {clicks}</button> <ThemeLabel /> </ThemeContext.Provider> ); } export function AppMemoValue() { const [theme] = useState("dark"); const [clicks, setClicks] = useState(0); const value = useMemo(() => ({ theme }), [theme]); // the SAME object until theme changes return ( <ThemeContext.Provider value={value}> <button onClick={() => setClicks(clicks + 1)}>Clicked {clicks}</button> <ThemeLabel /> </ThemeContext.Provider> ); }
Three clicks on the unrelated button - measured
AppNewObject ThemeLabel renders: 3 AppMemoValue ThemeLabel renders: 0

Fix: split the context

The usual fix for one big context is to split it: one context per kind of value. Now a component reads only the context it needs, and only that context’s changes reach it. The cart value is wrapped in useMemo so it changes only when the cart changes.

We clicked "Cart" again. UserMenu and ThemeLabel stayed at 0; only CartButton (and App, which owns the state) re-rendered. The cost: three Providers instead of one, and a little more code for every new kind of value. In a big app this becomes a stack of Providers at the top - which is part of why teams start to look at Redux.

Split.jsx
import { createContext, memo, useContext, useMemo, useState } from "react"; const UserContext = createContext(null); const ThemeContext = createContext(null); const CartContext = createContext(null); const UserMenu = memo(function UserMenu() { const user = useContext(UserContext); return <span>{user.name}</span>; }); const ThemeLabel = memo(function ThemeLabel() { const theme = useContext(ThemeContext); return <span>{theme}</span>; }); const CartButton = memo(function CartButton() { const { cart, addToCart } = useContext(CartContext); return <button onClick={() => addToCart("Redux")}>Cart ({cart.length})</button>; }); const Footer = memo(function Footer() { return <footer>© 2026</footer>; }); export default function App() { const [user] = useState({ name: "Ravi" }); const [theme] = useState("dark"); const [cart, setCart] = useState([]); const cartValue = useMemo( () => ({ cart, addToCart: (item) => setCart((c) => [...c, item]) }), [cart], ); return ( <UserContext.Provider value={user}> <ThemeContext.Provider value={theme}> <CartContext.Provider value={cartValue}> <UserMenu /> <ThemeLabel /> <CartButton /> <Footer /> </CartContext.Provider> </ThemeContext.Provider> </UserContext.Provider> ); }
Renders after one click on "Cart" - measured
App 1 (owns the state) UserMenu 0 ThemeLabel 0 CartButton 1 Footer 0

The same page with Redux

Now the same four components with a Redux store. Each one uses useSelector to pick exactly the piece it shows: state.user.name, state.theme, state.cart.items.length. The store keeps track of who selected what.

One click on "Cart": only CartButton re-rendered. UserMenu and ThemeLabel stayed at 0 - without memo, without splitting anything, and without useMemo. App also stayed at 0, because the state is not in App any more; it is in the store. This "read only one field" ability is the biggest practical difference between Context and Redux.

WithRedux.jsx
import { configureStore, createSlice } from "@reduxjs/toolkit"; import { Provider, useDispatch, useSelector } from "react-redux"; const cartSlice = createSlice({ name: "cart", initialState: { items: [] }, reducers: { added(state, action) { state.items.push(action.payload); }, }, }); const store = configureStore({ reducer: { user: () => ({ name: "Ravi" }), // tiny reducers that never change, for the example theme: () => "dark", cart: cartSlice.reducer, }, }); function UserMenu() { const name = useSelector((state) => state.user.name); return <span>{name}</span>; } function ThemeLabel() { const theme = useSelector((state) => state.theme); return <span>{theme}</span>; } function CartButton() { const count = useSelector((state) => state.cart.items.length); const dispatch = useDispatch(); return <button onClick={() => dispatch(cartSlice.actions.added("Redux"))}>Cart ({count})</button>; } function Footer() { return <footer>© 2026</footer>; } export default function App() { return ( <Provider store={store}> <UserMenu /> <ThemeLabel /> <CartButton /> <Footer /> </Provider> ); }
Renders after one click on "Cart" - measured
App 0 UserMenu 0 ThemeLabel 0 CartButton 1 Footer 0
One click on "Cart" - all three versions, measured
One big contextApp 1, UserMenu 1, ThemeLabel 1, CartButton 1, Footer 0
Split contextsApp 1, UserMenu 0, ThemeLabel 0, CartButton 1, Footer 0
ReduxApp 0, UserMenu 0, ThemeLabel 0, CartButton 1, Footer 0

"Context + useReducer is Redux" - almost

You may read that Context plus useReducer gives you Redux without the library. It is a good exercise, and it shows the division of work clearly: useReducer manages the state - a reducer function and actions, like Redux - and Context only delivers state and dispatch to the components. We ran it: Cart (0) -> Cart (1) -> Cart (2).

What it does not give you is everything around that core. Every consumer re-renders on every change (no useSelector). There are no Redux DevTools to see each action and step back through them. There is no middleware for logging, async logic or analytics. And there is no RTK Query for server data (Lesson 3). For a small feature it is a fine choice; for app-wide state these missing pieces are exactly what Redux adds.

MiniRedux.jsx - useReducer manages, Context delivers
import { createContext, useContext, useReducer } from "react"; // useReducer MANAGES the state. Context only DELIVERS it. function cartReducer(state, action) { switch (action.type) { case "cart/added": return { items: [...state.items, action.payload] }; default: return state; } } const CartContext = createContext(null); function CartButton() { const { state, dispatch } = useContext(CartContext); return ( <button onClick={() => dispatch({ type: "cart/added", payload: "Redux" })}> Cart ({state.items.length}) </button> ); } export default function App() { const [state, dispatch] = useReducer(cartReducer, { items: [] }); return ( <CartContext.Provider value={{ state, dispatch }}> <CartButton /> </CartContext.Provider> ); } // clicked twice: Cart (0) -> Cart (1) -> Cart (2)

What Context is great at: injecting things

Context shines when the value changes rarely or never: the theme, the language, the logged-in user, feature flags. Few changes means the "every consumer re-renders" cost almost never happens.

It is also the best tool for dependency injection - giving components a service they should use, without hard-coding it. Below, CourseList asks the context for "whatever API I should use". In the real app that is realApi, which calls the server. In a test or a design preview, a Provider injects fakeApi instead, and CourseList works with no server at all. We rendered TestApp and the list showed Python and Redux from the fake. Redux is not designed for this; Context is.

Inject.jsx - a context that holds a service
import { createContext, useContext, useEffect, useState } from "react"; // The real service: talks to the server const realApi = { async getCourses() { const response = await fetch("/api/courses"); return response.json(); }, }; // The context holds a SERVICE, not changing state export const ApiContext = createContext(realApi); export function CourseList() { const api = useContext(ApiContext); // "give me whatever API I should use" const [courses, setCourses] = useState([]); useEffect(() => { api.getCourses().then(setCourses); }, [api]); return <ul>{courses.map((c) => <li key={c}>{c}</li>)}</ul>; } // In a test or a Storybook story: inject a fake, no server needed const fakeApi = { getCourses: async () => ["Python", "Redux"] }; export function TestApp() { return ( <ApiContext.Provider value={fakeApi}> <CourseList /> </ApiContext.Provider> ); } // rendered TestApp -> list: Python, Redux

Context or Redux? A side-by-side

Neither tool is "better". They answer different questions. Context answers "how do I get this value to components far below without props?". Redux answers "how do I manage state that many parts of the app read and change, with clear rules and a record of every change?".

Context vs Redux
What it isContext: a way to deliver a value. Redux: a state manager.
Where the state livesContext: in useState / useReducer of some component. Redux: in the store.
Read one field onlyContext: no - every consumer re-renders on any change. Redux: yes - useSelector.
Rules for changesContext: whatever you write. Redux: actions and reducers, always.
See every changeContext: no. Redux: Redux DevTools and middleware.
Server dataContext: write it yourself. Redux: RTK Query.
SetupContext: built into React, very little code. Redux: a library and more code.
Best forContext: theme, language, auth user, injected services. Redux: frequently changing state shared across the app.

Using both together

In real apps the question is rarely "Context or Redux" for the whole app. A typical React app with Redux still uses Context: the theme and the language in Context, injected services in Context, and the shared, frequently changing data - cart, notifications, editor state - in Redux. In fact react-redux itself uses Context inside <Provider store={store}> to give every component access to the store; useSelector then adds the "subscribe to one field" part on top.

Common questions

Is Context slow? Not by itself. It re-renders every consumer when its value changes. If the value rarely changes - a theme - that cost almost never happens. It becomes a problem when a frequently changing value, like a cart or form text, sits in a context with many consumers.

Can I avoid the extra re-renders and still use Context? Partly: split contexts, keep the value stable with useMemo, and wrap children in memo. Each fix adds code, and none of them lets a component read only one field of an object - that needs a selector.

Does Redux use Context? Yes. The react-redux Provider puts the store in a context. The store object itself never changes, so that context never causes re-renders; the subscriptions made by useSelector decide who re-renders.

So when do I need Redux? Lesson 8 answers that with honest criteria - including cases where you should delete a store you already have.

This lesson at a glance

createContext

Make a context with a default value.

const ThemeContext = createContext("light")
Provider

Give the context a value for everything below.

<ThemeContext.Provider value={theme}>
useContext

Read the value from the nearest Provider above.

const theme = useContext(ThemeContext)
Stable value

Reuse the same object until its contents change.

useMemo(() => ({ theme }), [theme])
useReducer

Manage state with a reducer and actions.

const [state, dispatch] = useReducer(reducer, init)
useSelector

Redux: read one field; re-render only when it changes.

useSelector((state) => state.cart.items.length)

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Watch it spread

“In OneBig.jsx, add console.log("ThemeLabel render") to ThemeLabel and click Cart a few times.”

Stabilise the value

“In NewObject.jsx, click the unrelated button in both versions and count ThemeLabel renders.”

Forget the Provider

“Remove the Provider from Split.jsx. What error do you get? Then add the safer useUser hook.”

Inject a fake

“In Inject.jsx, make fakeApi return three courses, or make it throw an error. How does CourseList behave?”

What usually goes wrong

One context for everything

Every consumer re-renders when any part changes. Split by kind of value, or move frequently changing data to a store.

✗ <AppContext.Provider value={{ user, theme, cart, addToCart }}>
✓ <UserContext.Provider value={user}> ... <CartContext.Provider value={cartValue}>
A new value object on every render

value={{ theme }} is a new object each time, so every consumer re-renders even when theme did not change.

✗ <ThemeContext.Provider value={{ theme }}>
✓ const value = useMemo(() => ({ theme }), [theme]);
<ThemeContext.Provider value={value}>
Forgetting the Provider

useContext silently returns the default value. With null, the app crashes when a field is read.

✗ const user = useContext(UserContext);   // null without a Provider
✓ const user = useUser();   // throws a clear error if the Provider is missing
Calling Context a state manager

Context only delivers. The state is still managed by useState or useReducer - so the rules, the records and the selective updates are yours to build.

Practice

Write these yourself before opening anything. Getting them wrong first is most of how this sticks.

1.

In OneBig.jsx, which components re-rendered after one click on Cart, and which of those re-renders were unnecessary?

Show hint

Which components read the cart?

Show solution
Answer
Re-rendered: App, UserMenu, ThemeLabel, CartButton. Unnecessary: UserMenu and ThemeLabel - they read user and theme, which did not change. They re-rendered because they read the same context, and the context value changed.
2.

Choose Context or Redux for each: (a) the current language, (b) a list of open chat windows changed from many places, (c) an analytics service object, (d) the theme, (e) the items in a shopping cart shown in the header and changed on every product page.

Show hint

How often does it change, and is it a value or a service?

Show solution
Answer
(a) Context - rarely changes (b) Redux - changes often, read and changed from many places (c) Context - a service to inject (d) Context - rarely changes (e) Redux - changes often, shared widely
3.

Rewrite this Provider so ThemeLabel does not re-render when App re-renders for another reason: <ThemeContext.Provider value={{ theme, setTheme }}>

Show hint

Keep the same object until theme changes.

Show solution
Solution
const value = useMemo(() => ({ theme, setTheme }), [theme]); <ThemeContext.Provider value={value}> ... </ThemeContext.Provider> // setTheme from useState never changes, so [theme] is enough.
Coding challenge

Fix one big context

Start from OneBig.jsx. Without Redux, change it so that clicking Cart re-renders only CartButton (and App). Then write two sentences on what Redux would have given you for free.

It should
  • User, theme and cart in separate contexts
  • The cart context value is stable (useMemo) and changes only when the cart changes
  • Measured after one click: UserMenu 0, ThemeLabel 0, CartButton 1
  • Explain in two sentences what useSelector does that your solution does not
Show one solution
Solution - Split.jsx above (measured: UserMenu 0, ThemeLabel 0, CartButton 1)
The code is Split.jsx from this lesson: - UserContext, ThemeContext, CartContext instead of one AppContext - cartValue = useMemo(() => ({ cart, addToCart }), [cart]) - children wrapped in memo What Redux would give for free: useSelector lets each component read just one field of one store, so no splitting, memo or useMemo is needed to stop unrelated re-renders - and App would not re-render either, because the state would live in the store. It also records every change as an action you can inspect in Redux DevTools.

Key points

  • Context delivers a value to components below a Provider; it does not manage state.
  • The state still lives in useState or useReducer; Context is the pipe - dependency injection.
  • Without a Provider, useContext returns the default value - often null, and a crash.
  • Every consumer re-renders when the context value changes; you cannot read just one field.
  • One big context re-rendered UserMenu and ThemeLabel on a cart change; split contexts and Redux did not.
  • A new value object every render re-renders all consumers; keep it stable with useMemo.
  • Context + useReducer is a small Redux without selectors, DevTools, middleware or RTK Query.
  • Use Context for rarely changing values and injected services; Redux for frequently changing shared state - often both together.

Quick check before you move on

Is React Context a state manager?
No. It delivers a value to components below a Provider. The state itself is managed by useState or useReducer.
What does useContext return if there is no Provider above?
The default value given to createContext - for example null.
Why did ThemeLabel re-render when the cart changed in OneBig.jsx?
It reads the same context, and the context value changed. Every consumer re-renders when the value changes.
What does useMemo do for a Provider value?
It keeps the same object until its contents change, so consumers do not re-render when the parent re-renders for other reasons.
Name two good uses of Context.
Rarely changing values like the theme or language, and injecting services like an API client.

Interview questions

Is the Context API a replacement for Redux?

Not in general. Context is a dependency injection mechanism - it delivers a value through the tree. It does not manage state, offer selective subscriptions, enforce update rules, or provide tooling. Combined with useReducer it covers simple shared state; Redux adds selectors, DevTools, middleware and RTK Query.

What is the main performance pitfall of Context?

Every consumer re-renders whenever the provided value changes, and consumers cannot subscribe to part of it. A large context with frequently changing data, or a new value object created on every render, causes widespread re-renders.

How do you reduce Context re-renders?

Split contexts by concern, memoise the provided value with useMemo, separate state from dispatch, and memoise children where needed. For frequently changing shared state, a store with selectors is usually simpler.

When is Context the right tool?

For values that change rarely and are needed widely - theme, locale, authenticated user, feature flags - and for injecting services or configuration that tests can replace.

How does react-redux avoid the Context re-render problem?

It puts the store - an object that never changes - in context, so the context never triggers renders. Components subscribe to the store through useSelector and re-render only when their selected value changes.

Quiz

  1. 1.

    One click on Cart: renders with one big context, split contexts, and Redux?

  2. 2.

    Three clicks on an unrelated button: how many times did ThemeLabel re-render with value={{ theme }}, and with useMemo?

  3. 3.

    What does Context + useReducer lack compared with Redux?

  4. 4.

    Does react-redux use Context?

  5. 5.

    Why is Context good for injecting an API client?

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...