React Context and Shared State

React & Next.js: The Complete Developer HandbookLesson 11 of 54

Part 1: React Foundations · 11 of 16

Goal: share values such as the signed-in member and the borrowing basket with any component that needs them, without passing props through every layer, and know when Context is the wrong tool.

The tool library now has a header, a catalogue grid, tool cards and a basket. Several of them need the same information: the header greets the signed-in member, each card’s Reserve button needs to know whether anyone is signed in at all, and the basket count appears in more than one place. Passing all of that down by hand gets tedious fast. Context lets a component high in the tree make a value available to everything below it. Used well, it removes a lot of noise. Used for everything, it makes an app slower and harder to follow. This lesson covers both sides.

The problem: prop drilling

In Lesson 4 data flowed down as props, one level at a time. That is the right default: you can read any component and see exactly where its data comes from. But look at what happens when the Reserve button, four levels down, needs the signed-in member:

function CatalogPage({ member }) {
  return <ToolGrid tools={TOOLS} member={member} />;
}

function ToolGrid({ tools, member }) {
  return tools.map((tool) => <ToolCard key={tool.id} tool={tool} member={member} />);
}

function ToolCard({ tool, member }) {
  return (
    <article>
      <h3>{tool.name}</h3>
      <ReserveButton tool={tool} member={member} />
    </article>
  );
}

CatalogPage, ToolGrid and ToolCard don’t use member at all. They only pass it along. This is called prop drilling. A few levels of it is fine and honest. It becomes a problem when the same value has to reach many components at different depths, when every new layer in between must remember to forward it, and when renaming the prop means touching files that have nothing to do with members.

Before reaching for Context, check whether composition solves it. If CatalogPage accepted the cards as children (Lesson 4), the component that owns member could render <ReserveButton member={member} /> itself and the layers in between would never see it. When the value is genuinely needed all over the tree, Context is the tool.

Create, provide, read

Context has three parts:

  1. Create it once, outside any component, with createContext(defaultValue). This gives you a context object, a named “channel”.
  2. Provide a value by wrapping part of the tree in <MemberContext value={…}>. Everything inside can now read that value.
  3. Read it in any component below with useContext(MemberContext).

Here the member lives in state at the top, and only ReserveButton reads it. ToolGrid and ToolCard no longer mention members at all. Press Sign in as Amina and watch both buttons change:

import { createContext, useContext, useState } from 'react';

const GUEST = { name: null, role: 'guest' };
const MemberContext = createContext(GUEST);

function ReserveButton({ toolName }) {
  const member = useContext(MemberContext);
  if (member.role === 'guest') {
    return <span>Sign in to reserve</span>;
  }
  return <button>Reserve {toolName}</button>;
}

function ToolCard({ name }) {
  return (
    <li>
      {name}: <ReserveButton toolName={name} />
    </li>
  );
}

function ToolGrid() {
  return (
    <ul>
      <ToolCard name="Cordless drill" />
      <ToolCard name="Tile cutter" />
    </ul>
  );
}

function App() {
  const [member, setMember] = useState(GUEST);
  const signedIn = member.role !== 'guest';

  return (
    <MemberContext value={member}>
      <button onClick={() => setMember(signedIn ? GUEST : { name: 'Amina', role: 'member' })}>
        {signedIn ? 'Sign out' : 'Sign in as Amina'}
      </button>
      <ToolGrid />
    </MemberContext>
  );
}

Notice that Context does not hold state by itself. The state is an ordinary useState in App; Context only delivers the current value downwards. When setMember runs, App re-renders with a new member, the provider passes the new value on, and every component reading MemberContext re-renders with it.

Two version notes. Rendering the context itself as the provider, <MemberContext value={…}>, is new in React 19. Older code (and plenty of tutorials) writes <MemberContext.Provider value={…}>, which does the same job and which you will meet in existing projects. React 19 also lets you read a context with use(MemberContext); unlike useContext, use may be called inside an if. useContext remains the everyday choice, and this course uses it.

Default values and the nearest provider

useContext looks upwards from the component that calls it and uses the value of the nearest provider above it. If there is no provider at all, it gets the default value you passed to createContext. Providers can also be nested, and an inner one overrides an outer one for its own subtree. Predict the three lines before you run this:

import { createContext, useContext } from 'react';

const MemberContext = createContext({ name: null, role: 'guest' });

function Greeting() {
  const member = useContext(MemberContext);
  if (member.role === 'guest') {
    return <p>Hello, guest</p>;
  }
  return <p>Hello, {member.name} ({member.role})</p>;
}

function App() {
  return (
    <>
      <Greeting />
      <MemberContext value={{ name: 'Amina', role: 'member' }}>
        <Greeting />
        <MemberContext value={{ name: 'Tomasz', role: 'volunteer' }}>
          <Greeting />
        </MemberContext>
      </MemberContext>
    </>
  );
}

It shows “Hello, guest”, “Hello, Amina (member)” and “Hello, Tomasz (volunteer)”. The first Greeting has no provider above it, so it gets the default. The other two each find a different nearest provider. Nesting like this is handy for previews (a volunteer checking how a page looks to an ordinary member) and for tests, where you wrap a component in a provider with whatever value the test needs.

The default value is a fallback, not a source of truth. A component that silently gets the default because someone forgot the provider is a quiet bug: everything renders, just with the wrong data. For contexts that make no sense without a provider, such as the basket below, use null as the default and fail loudly.

A basket context with a reducer

Lesson 10 moved the basket into a reducer. Now several components need it: the cards add and remove tools, and a summary shows the count and a Clear button. Combining useReducer with Context is a common pattern. It has three pieces: a provider component that owns the reducer, a context that carries the state and dispatch, and a small hook so components never touch the context object directly. (Lesson 12 is about writing hooks like useBasket.)

import { createContext, useContext, useReducer } from 'react';

const BasketContext = createContext(null);

function basketReducer(basket, action) {
  switch (action.type) {
    case 'added': return [...basket, action.toolId];
    case 'removed': return basket.filter((id) => id !== action.toolId);
    case 'cleared': return [];
    default: throw new Error('Unknown action: ' + action.type);
  }
}

function BasketProvider({ children }) {
  const [basket, dispatch] = useReducer(basketReducer, []);
  return <BasketContext value={{ basket, dispatch }}>{children}</BasketContext>;
}

function useBasket() {
  const value = useContext(BasketContext);
  if (value === null) throw new Error('useBasket must be used inside BasketProvider');
  return value;
}

function ToolCard({ id, name }) {
  const { basket, dispatch } = useBasket();
  if (basket.includes(id)) {
    return <li>{name} <button onClick={() => dispatch({ type: 'removed', toolId: id })}>Remove</button></li>;
  }
  return <li>{name} <button disabled={basket.length >= 3} onClick={() => dispatch({ type: 'added', toolId: id })}>Add</button></li>;
}

function BasketSummary() {
  const { basket, dispatch } = useBasket();
  return <p>{basket.length} of 3 tools <button onClick={() => dispatch({ type: 'cleared' })}>Clear</button></p>;
}

function App() {
  return (
    <BasketProvider>
      <ul>
        <ToolCard id="t1" name="Cordless drill" />
        <ToolCard id="t3" name="Orbital sander" />
        <ToolCard id="t6" name="Tile cutter" />
        <ToolCard id="t7" name="Stud finder" />
      </ul>
      <BasketSummary />
    </BasketProvider>
  );
}

Click Add on the cordless drill: its button turns into Remove and the summary reads “1 of 3 tools”. Add two more and the fourth card’s Add button is disabled, because the “three tools at once” rule is derived from the basket on every render. The cards and the summary never pass anything to each other; they share one source of truth through the provider.

Two details are worth copying. The null default plus the check in useBasket means that a component rendered outside BasketProvider throws a clear error on its first render, instead of quietly showing an empty basket. And because components call useBasket() rather than useContext(BasketContext), you could later change how the basket is stored without editing a single card.

Who re-renders when the value changes

When a provider receives a different value, React re-renders every component below it that reads that context, however deep, and even if a component in between was told to skip re-rendering (the memo function in Lesson 13). “Different” is checked with Object.is, the same comparison React uses for state: a new object counts as different, even when its contents are identical.

Components that do not read the context are a different story. The example below logs each render. BasketProvider owns the state and receives the rest of the page as children. Run it and press Add cordless drill:

import { createContext, useContext, useReducer } from 'react';

const BasketContext = createContext(null);

function basketReducer(basket, action) {
  return action.type === 'added' ? [...basket, action.toolId] : basket;
}

function BasketProvider({ children }) {
  const [basket, dispatch] = useReducer(basketReducer, []);
  return <BasketContext value={{ basket, dispatch }}>{children}</BasketContext>;
}

function AddDrillButton() {
  const { dispatch } = useContext(BasketContext);
  console.log('AddDrillButton rendered');
  return <button onClick={() => dispatch({ type: 'added', toolId: 't1' })}>Add cordless drill</button>;
}

function BasketCount() {
  const { basket } = useContext(BasketContext);
  console.log('BasketCount rendered');
  return <p>Basket: {basket.length}</p>;
}

function CatalogueIntro() {
  console.log('CatalogueIntro rendered');
  return <p>Borrow tools from your neighbours.</p>;
}

function App() {
  return (
    <BasketProvider>
      <AddDrillButton />
      <BasketCount />
      <CatalogueIntro />
    </BasketProvider>
  );
}

On the first render all three log once. After the click, the count shows 1 and only AddDrillButton and BasketCount log again. CatalogueIntro does not, because it doesn’t read the context and its element was created by App, which did not re-render; only BasketProvider‘s own state changed. That is why a provider component that takes children is a good shape: a basket update re-renders the readers, not the whole page.

Now look at AddDrillButton. It only uses dispatch, which never changes, yet it re-rendered, because the provider’s value is a new { basket, dispatch } object every time. In a page with dozens of Add buttons that is wasted work. The usual fix is to split the context in two, one for the state and one for the function that changes it:

import { createContext, useContext, useReducer } from 'react';

const BasketContext = createContext(null);
const BasketDispatchContext = createContext(null);

function basketReducer(basket, action) {
  return action.type === 'added' ? [...basket, action.toolId] : basket;
}

function BasketProvider({ children }) {
  const [basket, dispatch] = useReducer(basketReducer, []);
  return (
    <BasketContext value={basket}>
      <BasketDispatchContext value={dispatch}>{children}</BasketDispatchContext>
    </BasketContext>
  );
}

function AddDrillButton() {
  const dispatch = useContext(BasketDispatchContext);
  console.log('AddDrillButton rendered');
  return <button onClick={() => dispatch({ type: 'added', toolId: 't1' })}>Add cordless drill</button>;
}

function BasketCount() {
  const basket = useContext(BasketContext);
  console.log('BasketCount rendered');
  return <p>Basket: {basket.length}</p>;
}

function App() {
  return (
    <BasketProvider>
      <AddDrillButton />
      <BasketCount />
    </BasketProvider>
  );
}

After the click only BasketCount logs. dispatch from useReducer is the same function for the life of the component, so the dispatch context’s value never changes and its readers are left alone.

One more case: if the provider’s parent re-renders for an unrelated reason and the provider builds a fresh object like { basket, dispatch }, every reader re-renders even though nothing in the basket changed. When that turns out to matter, wrap the object in useMemo (Lesson 10) so it only changes when basket does: const value = useMemo(() => ({ basket, dispatch }), [basket]);. Don’t do this by reflex for a value that changes rarely; do it when a measurement (Lesson 13) points at it.

What does not belong in Context

Context is good for values that many distant components need and that change occasionally: the signed-in member, a display setting, the language, the basket. It is a poor fit for values that change constantly or that only a couple of nearby components use.

The catalogue’s search text is the classic example. It is tempting to put it in a SearchContext “in case something needs it”. But it changes on every keystroke, so every reader re-renders on every keystroke, and only two components care: the search box and the grid it filters. Their nearest common parent is the catalogue page, so that is where the state belongs (lifting state up, Lesson 5), passed down as one or two props:

function CatalogPage() {
  const [query, setQuery] = useState('');
  const visible = TOOLS.filter((tool) =>
    tool.name.toLowerCase().includes(query.trim().toLowerCase())
  );

  return (
    <>
      <SearchBar query={query} onQueryChange={setQuery} />
      <ToolGrid tools={visible} />
    </>
  );
}

Keeping it local also keeps the data flow readable: anyone can see that the grid depends on the query by reading one component. In Part 2 you will go one step further and keep the query in the URL (?q=drill), so a search can be bookmarked and shared. Lesson 43 sorts application state into these kinds (server, URL, form and UI state) and shows where each lives.

A second boundary: Context is not a security feature. The role in MemberContext decides what the interface shows, for example hiding the “Edit catalogue” link from ordinary members. Anyone can change client-side code in their browser’s developer tools, so the server must check permissions on every request that changes data (Lesson 39). Never put secrets such as API keys in Context either: everything in a client component’s tree is visible to the user.

Common mistakes

  • Context as the default home for all state. A single AppContext holding the member, basket, search text, open modals and form drafts. Every change re-renders every reader, and nobody can tell which component depends on what. In review, ask for each value: how many distant components read it, and how often does it change?
  • Forgetting the provider. The component renders with the default value and no error. Spot it by using a null default and a hook such as useBasket that throws a clear message when the provider is missing.
  • Providing a fresh object on every render without noticing. value={{ basket, dispatch }} is fine when the provider only re-renders because the basket changed. If its parent re-renders often, readers re-render too. Split the context or memoise the value once the Profiler shows it matters.
  • Calling useContext in the same component that renders the provider. useContext looks for providers above the calling component, so it can’t see a provider that component renders itself. Move the reading code into a child component.
  • Trusting Context for permissions. Hiding a button because member.role !== 'volunteer' is good UX, not protection. The matching server endpoint must check the role too.

Build it: a large-text setting

Some members of the library find the catalogue hard to read and have asked for a larger text option. The setting is chosen in the header and affects the tool cards and the footer, which sit in different branches of the tree. The starter passes it down as props through components that don’t use it. Replace the drilling with a DisplayContext:

  • Create DisplayContext with the default 'normal'.
  • Keep the setting in state in App and provide it around the page.
  • ToolCard and SiteFooter read it with useContext. ToolGrid should no longer mention the setting.
  • The button keeps working and keeps its aria-pressed state.

Then write one sentence on why the catalogue’s search text should not go into a context like this one.

import { useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill' },
  { id: 't6', name: 'Tile cutter' },
];

function ToolCard({ tool, textSize }) {
  return <li style={{ fontSize: textSize === 'large' ? 22 : 16 }}>{tool.name}</li>;
}

function ToolGrid({ textSize }) {
  return <ul>{TOOLS.map((tool) => <ToolCard key={tool.id} tool={tool} textSize={textSize} />)}</ul>;
}

function SiteFooter({ textSize }) {
  return <footer style={{ fontSize: textSize === 'large' ? 18 : 13 }}>Open Saturdays 10:00 to 14:00</footer>;
}

function App() {
  const [textSize, setTextSize] = useState('normal');
  const large = textSize === 'large';
  return (
    <>
      <button aria-pressed={large} onClick={() => setTextSize(large ? 'normal' : 'large')}>
        Large text
      </button>
      <ToolGrid textSize={textSize} />
      <SiteFooter textSize={textSize} />
    </>
  );
}

Hint: the button stays in App because App owns the state; only the readers change. Wrap the grid and the footer in <DisplayContext value={textSize}>.

Show a sample solution
import { createContext, useContext, useState } from 'react';

const DisplayContext = createContext('normal');

const TOOLS = [
  { id: 't1', name: 'Cordless drill' },
  { id: 't6', name: 'Tile cutter' },
];

function ToolCard({ tool }) {
  const textSize = useContext(DisplayContext);
  return <li style={{ fontSize: textSize === 'large' ? 22 : 16 }}>{tool.name}</li>;
}

function ToolGrid() {
  return <ul>{TOOLS.map((tool) => <ToolCard key={tool.id} tool={tool} />)}</ul>;
}

function SiteFooter() {
  const textSize = useContext(DisplayContext);
  return <footer style={{ fontSize: textSize === 'large' ? 18 : 13 }}>Open Saturdays 10:00 to 14:00</footer>;
}

function App() {
  const [textSize, setTextSize] = useState('normal');
  const large = textSize === 'large';
  return (
    <DisplayContext value={textSize}>
      <button aria-pressed={large} onClick={() => setTextSize(large ? 'normal' : 'large')}>
        Large text
      </button>
      <ToolGrid />
      <SiteFooter />
    </DisplayContext>
  );
}

ToolGrid is back to describing a grid; it has no idea a text setting exists. The value here is a string, so there is no new-object problem: the readers re-render only when the setting actually changes. As for the search text: it changes on every keystroke and only the search box and the grid use it, so it belongs in state in their nearest common parent (the catalogue page), not in a context that would re-render every reader on each key press.

Check your understanding

Answer each question in a sentence or two before opening the answer.

1. The header shows “Hello, guest” even though a member is signed in, while the tool cards correctly show Reserve buttons. What is the most likely cause, and how would you confirm it?

The header is rendered outside (above or beside) the MemberContext provider, so useContext finds no provider and returns the default value. Confirm it in the React DevTools Components tab: select the header and look at its parents for the provider, or temporarily change the default to something obvious such as { name: 'NO PROVIDER', role: 'guest' }. Moving the provider higher fixes it; a null default with a throwing hook would have caught it immediately.

2. A teammate wraps the whole app in a SearchContext holding the catalogue’s search text. Typing now feels sluggish on slower phones. Why?

Every keystroke gives the provider a new value, so every component that reads the context re-renders, wherever it is in the tree, and memo on the components in between does not stop it. The search text is only needed by the search box and the grid, so it should live in their nearest common parent and be passed as props.

3. Why does splitting the basket into a state context and a dispatch context stop the Add buttons from re-rendering when the basket changes?

The Add buttons only read the dispatch context. Its value is the dispatch function from useReducer, which stays the same for the life of the provider, so that context never receives a different value and its readers are not re-rendered by basket changes. Only readers of the state context are.

Key takeaways

  • Context delivers a value to any component below a provider: createContext, <MyContext value={…}> (React 19; .Provider in older code), useContext.
  • It does not store state itself. Pair it with useState or useReducer in a provider component, and expose it through a small hook that fails loudly without the provider.
  • When the value changes (by Object.is), every reader re-renders. Split contexts that change at different rates, and keep fast-changing values such as search text out of Context.
  • Try props and composition first. Context is for values that many distant components need; it is never a security boundary.

Priority: should know. You should be able to set up a provider, read it from deep in the tree and explain why a given value does or doesn’t belong in Context. For the exact API, see useContext and createContext in the React documentation.

Code examples in this course are free to use under the MIT licence. Lesson text © 2026 Farooq Bin Munir.