Blog

Part 24 · The Provider Pattern in React

A provider component shares a value, usually its own state, through context. Learn the reducer and context pattern, why only readers render, two contexts, useMemo for the value, and where providers go.

In Part 15 you kept a shopping cart in a reducer. In Part 23 you learned context: createContext, <SomeContext value> and useContext. This part puts the two together.

The result has a name: a provider component. It shares a value, usually state it owns, through context, and wraps the components that need it. You wrap part of your app in it, like <CartProvider>...</CartProvider>. Then any component inside can read the cart or change it, with no props in between.

We’ll build one, step by step. Then we’ll count renders. A provider can be cheap or costly, and a few small choices decide which. Those choices come from Part 19, on children, and Part 21, on useMemo.

Try this first

Read this code, but don’t press Run yet.

import { createContext, useContext, useReducer, type Dispatch, type ReactNode } from 'react'

type Action = { type: 'added'; name: string } | { type: 'emptied' }

function cartReducer(cart: string[], action: Action): string[] {
  switch (action.type) {
    case 'added':
      return [...cart, action.name]
    case 'emptied':
      return []
  }
}

const CartContext = createContext<string[]>([])
const CartDispatchContext = createContext<Dispatch<Action>>(() => {})

function CartProvider({ children }: { children: ReactNode }) {
  const [cart, dispatch] = useReducer(cartReducer, [])
  console.log('CartProvider renders')
  return (
    <CartContext value={cart}>
      <CartDispatchContext value={dispatch}>{children}</CartDispatchContext>
    </CartContext>
  )
}

function Header() {
  console.log('Header renders')
  return <h1>Fruit shop</h1>
}

function AddButton({ name }: { name: string }) {
  const dispatch = useContext(CartDispatchContext)
  console.log('AddButton renders')
  return <button onClick={() => dispatch({ type: 'added', name })}>Add {name}</button>
}

function CartSummary() {
  const cart = useContext(CartContext)
  console.log('CartSummary renders')
  return <p>In the cart: {cart.length}</p>
}

export default function App() {
  return (
    <CartProvider>
      <Header />
      <AddButton name="Apple" />
      <CartSummary />
    </CartProvider>
  )
}
CartProvider renders
CartProvider renders
Header renders
Header renders
AddButton renders
AddButton renders
CartSummary renders
CartSummary renders
CartProvider renders
CartProvider renders
CartSummary renders
CartSummary renders
CartProvider renders
CartProvider renders
CartSummary renders
CartSummary renders

The cart is a list of fruit names. The reducer can add a name or empty the list. Dispatch<Action> is the TypeScript type of a dispatch function for these actions.

Make a guess. You click “Add Apple” two times. The cart changes, and it lives in CartProvider, at the top. Which of the four components render again?

Press Run, click “Add Apple” two times, and look at the Console.

Each line comes in a pair. That is Strict Mode, which calls each component twice in development, as Part 2 showed. So read the lines two at a time.

The first eight lines are the first render: all four components. Then each click logs only two names: CartProvider and CartSummary. Header never runs again. Neither does AddButton, even though it is the button you clicked.

The rest of this part explains why.

What a provider component is

Look at CartProvider again. It does three jobs.

  1. It shares a value through context. Here it uses two contexts: CartContext for the cart, and CartDispatchContext for dispatch.
  2. It wraps its children. It takes a children prop and puts it inside the contexts. It doesn’t know or care what those children are.
  3. It owns the value it shares. Here the state comes from useReducer. It could also come from useState.

So this is what we’ll call a provider component. It is a component that shares a value (usually state it owns) through context and wraps its children. Most provider components do all three jobs. You’ll see one later that shares a prop instead of its own state. Using it to share state is called the provider pattern. A pattern is a way of writing code that many people use for the same kind of problem.

App uses the provider like a box. Whatever goes inside <CartProvider> can read the cart, no matter how deep it is.

The components inside each read only what they need. CartSummary shows the cart, so it reads CartContext. AddButton only changes the cart, so it reads CartDispatchContext. Header reads nothing.

The recipe from React’s docs

React’s docs have a page on this, “Scaling Up with Reducer and Context”. It gives three steps:

  1. Create the context. The docs make “two separate contexts”: one for the state, and one for the dispatch function.
  2. Put state and dispatch into context, in the component that calls useReducer.
  3. Use context anywhere in the tree.

Then the docs move all of this into one component, a TasksProvider, which takes children. Our CartProvider is the same shape. The docs say why it helps: it “will tie all the pieces together”.

That page doesn’t say why there are two contexts. We’ll measure it instead, a little further down.

Why only the readers render

Here is what happens on one click, step by step. Choose “Two contexts” for the code above. “One context” is a version we’ll look at in the next section. In the figure, “stays still” means the component doesn’t render.

1. CartProvider holds the cart. App made the three children. 2. You click Add Apple. AddButton calls dispatch. 3. React renders CartProvider. Its children are the same as before. 4. CartContext has a new array, so CartSummary renders again. 5. dispatch is the same, so AddButton stays still. So does Header. 6. React commits. The page shows In the cart: 1. CartProvider cart: [] two contexts: cart, dispatch children: the same elements as before Header AddButton CartSummary reads nothing stays still reads dispatch stays still reads cart stays still the page Fruit shop [ Add Apple ] In the cart: 0

What renders after a dispatch. Choose a case, then press play or step through it.

The same steps, in words:

  1. CartProvider holds the cart. App made the <Header />, <AddButton /> and <CartSummary /> elements, and passed them in as children.
  2. You click “Add Apple”. AddButton calls dispatch.
  3. The cart in CartProvider changed, so React renders CartProvider again. It returns its JSX, with {children} in it. But App didn’t render, so children is the same object as last time.
  4. CartContext now has a new array. React renders every component that reads it. That is CartSummary.
  5. CartDispatchContext still has the same dispatch. So AddButton has nothing new, and React skips it. Header reads no context at all, so React skips it too.
  6. React commits: it changes the page. It now shows “In the cart: 1”.

Step 3 is the trick from Part 19. The component that holds the state didn’t make the elements inside it. App made them. We checked, with Strict Mode off and 3 clicks. CartProvider rendered 4 times: once at the start, then once per click. It got the very same children object all 4 times.

Step 4 is the rule from Part 23. React’s docs say it “automatically re-renders all the children that use a particular context”. That starts “from the provider that receives a different value”. React compares the old and new values with Object.is. A new array is never Object.is equal to the old one.

We counted renders after 3 clicks, not counting the first render.

Component What it reads Renders (Strict Mode off) Renders (Strict Mode on)
CartProvider it owns the cart 3 6
Header nothing 0 0
AddButton dispatch 0 0
CartSummary the cart 3 6

What if the provider writes the children itself?

Say we don’t take children. Instead the component with the reducer writes <Header />, <AddButton /> and <CartSummary /> in its own JSX. We tried it, in a component called Shop with the same contexts inside.

After 3 clicks, every component rendered 3 times with Strict Mode off: Shop, Header, AddButton and CartSummary. Now Shop makes new elements each time it renders, so React has to render all of them. The contexts don’t matter any more.

So a provider component takes children. That one choice means the rest of the app doesn’t render.

An everyday example

Think of a water tank on the roof of a building. Pipes go from the tank to some of the homes. When the tank gets new water, only the taps on those pipes get it. A home with no pipe to the tank doesn’t notice anything.

The provider is the tank. Context is the pipe. A component that calls useContext has a tap.

The exact version

The pipes picture is close, but three things are different.

  • A reader renders when the value is a new object, even if it looks the same inside. React only checks Object.is. So a new array with the same names still counts as new.
  • A reader renders for the whole value, even if it uses only part of it. Part 25 shows ways to read only one part.
  • A component skipped in step 5 can still render for other reasons. Its own state can change, or its parent can make a new element. Part 18 listed them.

Two contexts: one for the state, one for dispatch

Part 23 split a context in two: one for a value, one for its set function. A provider with a reducer does the same, with dispatch in place of the set function. Why not put the cart and dispatch together, in one context? It is less code. Here is that version. Only the context part has changed:

import { createContext, useContext, useReducer, type Dispatch, type ReactNode } from 'react'

type Action = { type: 'added'; name: string } | { type: 'emptied' }

function cartReducer(cart: string[], action: Action): string[] {
  switch (action.type) {
    case 'added':
      return [...cart, action.name]
    case 'emptied':
      return []
  }
}

type CartValue = { cart: string[]; dispatch: Dispatch<Action> }
const CartContext = createContext<CartValue>({ cart: [], dispatch: () => {} })

function CartProvider({ children }: { children: ReactNode }) {
  const [cart, dispatch] = useReducer(cartReducer, [])
  console.log('CartProvider renders')
  return <CartContext value={{ cart, dispatch }}>{children}</CartContext>
}

function Header() {
  console.log('Header renders')
  return <h1>Fruit shop</h1>
}

function AddButton({ name }: { name: string }) {
  const { dispatch } = useContext(CartContext)
  console.log('AddButton renders')
  return <button onClick={() => dispatch({ type: 'added', name })}>Add {name}</button>
}

function CartSummary() {
  const { cart } = useContext(CartContext)
  console.log('CartSummary renders')
  return <p>In the cart: {cart.length}</p>
}

export default function App() {
  return (
    <CartProvider>
      <Header />
      <AddButton name="Apple" />
      <CartSummary />
    </CartProvider>
  )
}
CartProvider renders
CartProvider renders
Header renders
Header renders
AddButton renders
AddButton renders
CartSummary renders
CartSummary renders
CartProvider renders
CartProvider renders
AddButton renders
AddButton renders
CartSummary renders
CartSummary renders

Run it and click “Add Apple” once. Now the click logs AddButton too.

{{ cart, dispatch }} makes a new object every time CartProvider renders. So the context value is new on every click. Every reader renders, and AddButton is a reader now. It renders, even though dispatch itself never changed. Choose “One context” in the figure above to step through it.

Could useMemo save it? We tried useMemo(() => ({ cart, dispatch }), [cart, dispatch]). After 3 clicks, AddButton still rendered 3 times with Strict Mode off. useMemo keeps the object only while cart stays the same. But every click changes cart, so every click makes a new object.

Two contexts fix it, because each one changes on its own:

  • The cart changes on every click. Only CartSummary reads it.
  • dispatch never changes. Part 15 showed it: React’s docs say “The dispatch function has a stable identity”. We checked again here, with Strict Mode off: CartProvider got the same dispatch on all 4 renders.

So a component that only sends actions should read only the dispatch context. A button, a form or a menu often just changes the state. With two contexts, the state can change many times, and they don’t render.

When the provider’s parent renders

Part 23 showed the object trap. A value written as an object in the JSX, like value={{ user, setUser }}, is a new object on every render. So every reader renders, even when nothing inside changed. Part 23’s Fix 1 kept the object with useMemo.

The same is true inside a provider component. What is new is when a provider component renders. There are two reasons:

  • its own state changes. Then its value really is new, and its readers should render.
  • its parent renders. Then the provider runs again, and makes its value again. If the value is written as an object, it is a new object, though nothing in it changed.

So the trap matters when a provider’s parent renders often. We measured it with a ThemeProvider. Its value was { color, toggle }, made in its body. A ThemeButton read it. The provider’s parent had a click counter of its own. We clicked the counter 3 times, so the theme never changed. This is how many times ThemeButton rendered (Strict Mode off):

How ThemeButton sits below the provider Value made new each render Value kept with useMemo
Inside a Page that the parent writes itself 3 3
Inside a Page wrapped in memo (Part 20) 3 0
Inside a <Page /> made higher up and passed in as children 3 0

The first row is the surprise. With no memo and no children, Page rendered 3 times, because its parent rendered. So ThemeButton rendered too, and useMemo saved nothing.

useMemo on the value helps only when something stops the render on the way down. That is memo, or elements that came from above as children, as in “Try this first”. Then the context is the only way left for the render to reach the reader. And useMemo keeps that way closed until the value really changes.

The playground doesn’t run the React Compiler, and the Part 10 project doesn’t either. Part 22 showed what it does. We built the same theme app with the compiler (babel-plugin-react-compiler 1.0.0). It kept the value object by itself, until color changed. ThemeButton rendered 0 times after the 3 clicks, with memo on Page and without it.

Without the compiler, should you add useMemo? React’s useContext page shows this fix. Then it says: “In smaller apps, this is not a problem.” It calls the fix “a performance optimization”. Performance means how fast the app is. So add it when a provider’s parent renders often and the readers are slow. Part 21 showed how to tell if a calculation is slow. The Profiler in Part 36 shows which components render, and for how long.

Custom hooks: useCart() and useCartDispatch()

In “Try this first”, the contexts had default values: an empty cart, and a dispatch that does nothing. That hides a mistake. Put a reader outside the provider, and it quietly shows an empty cart forever. You’ll try it in Practice.

Part 23 showed the fix for one context. Make the default null. Then read the context through a custom hook (Part 16) that throws a clear error when it gets null. A provider with two contexts gets two hooks, one for each:

import { createContext, useContext, useReducer, type Dispatch, type ReactNode } from 'react'

type Action = { type: 'added'; name: string } | { type: 'emptied' }

function cartReducer(cart: string[], action: Action): string[] {
  switch (action.type) {
    case 'added':
      return [...cart, action.name]
    case 'emptied':
      return []
  }
}

const CartContext = createContext<string[] | null>(null)
const CartDispatchContext = createContext<Dispatch<Action> | null>(null)

function CartProvider({ children }: { children: ReactNode }) {
  const [cart, dispatch] = useReducer(cartReducer, [])
  return (
    <CartContext value={cart}>
      <CartDispatchContext value={dispatch}>{children}</CartDispatchContext>
    </CartContext>
  )
}

function useCart() {
  const cart = useContext(CartContext)
  if (cart === null) {
    throw new Error('useCart must be used inside a CartProvider')
  }
  return cart
}

function useCartDispatch() {
  const dispatch = useContext(CartDispatchContext)
  if (dispatch === null) {
    throw new Error('useCartDispatch must be used inside a CartProvider')
  }
  return dispatch
}

function AddButton({ name }: { name: string }) {
  const dispatch = useCartDispatch()
  return <button onClick={() => dispatch({ type: 'added', name })}>Add {name}</button>
}

function CartSummary() {
  const cart = useCart()
  return <p>In the cart: {cart.join(', ') || 'nothing'}</p>
}

export default function App() {
  return (
    <CartProvider>
      <AddButton name="Apple" />
      <AddButton name="Pear" />
      <CartSummary />
    </CartProvider>
  )
}

Run it, and click both buttons. The cart shows Apple, Pear. cart.join(', ') makes one text from the names, with , between them. || 'nothing' shows “nothing” when that text is empty.

Now press Edit. Move <AddButton name="Apple" /> up, above <CartProvider>, and wrap everything in a Fragment, <>.... Run it. The app stops with this message:

useCartDispatch must be used inside a CartProvider

Part 23 explained what the null check does for TypeScript. The provider adds one more gain. The components only call useCart() and useCartDispatch(). They don’t know there are two contexts. React’s docs say these hooks let you “later split these contexts further or add some logic to these functions”. The components won’t need to change.

One file, or three?

In a real project, this code moves out of App.tsx. React’s docs put the reducer, the contexts, the provider and the hooks in one file. It exports the provider and the hooks, and not the contexts. The docs call the provider “a part of the screen that knows how to deal with tasks”.

We tried that in the Part 10 project: one file, src/CartContext.tsx, that exports CartProvider, useCart and useCartDispatch. The project’s linter, Oxlint, warned twice, once for each hook:

! react(only-export-components): Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.

Part 10 met this warning. Fast Refresh is how the page updates when you save a file, and keeps its state. The notes of Vite’s React plugin say: “For React refresh to work correctly, your file should only export React components”. They also say what happens when a file breaks the rule. In plain words, Fast Refresh can’t update just that file. The update goes further up, to the files that import it.

So we split it into three files, as the warning asks:

src/cartContexts.ts    the Action type, cartReducer, CartContext, CartDispatchContext
src/CartProvider.tsx   only CartProvider
src/useCart.ts         useCart and useCartDispatch

Then Oxlint found 0 warnings, and TypeScript found no errors. The contexts are now exported from cartContexts.ts. That’s fine, but the rest of the app should import only from CartProvider.tsx and useCart.ts.

This shape is also easy to test. A test can wrap a component in <CartProvider>, the same way App does. Part 49 covers testing components.

Many providers: keep them in one place

A real app often has several providers: a user, a theme, a cart, a language. Each one wraps the next, so the JSX gets deeper and deeper. It gets hard to read.

One simple fix is to put them all in one component, here called AppProviders. Then App stays short:

import { createContext, useContext, type ReactNode } from 'react'

const UserContext = createContext<string | null>(null)
const ThemeContext = createContext<string | null>(null)

function useUser() {
  const user = useContext(UserContext)
  if (user === null) {
    throw new Error('useUser must be used inside a UserProvider')
  }
  return user
}

function useTheme() {
  const theme = useContext(ThemeContext)
  if (theme === null) {
    throw new Error('useTheme must be used inside a ThemeProvider')
  }
  return theme
}

function UserProvider({ name, children }: { name: string; children: ReactNode }) {
  return <UserContext value={name}>{children}</UserContext>
}

function ThemeProvider({ children }: { children: ReactNode }) {
  const user = useUser()
  const theme = user === 'Ana' ? 'dark' : 'light'
  return <ThemeContext value={theme}>{children}</ThemeContext>
}

function AppProviders({ children }: { children: ReactNode }) {
  return (
    <UserProvider name="Ana">
      <ThemeProvider>{children}</ThemeProvider>
    </UserProvider>
  )
}

function Greeting() {
  const user = useUser()
  const theme = useTheme()
  return <p>Hello, {user}. Your theme is {theme}.</p>
}

export default function App() {
  return (
    <AppProviders>
      <Greeting />
    </AppProviders>
  )
}

Run it. It shows “Hello, Ana. Your theme is dark.”

UserProvider has no state. It shares a prop, name, through context, and wraps its children. That still fits our definition: the value is “usually” state it owns, not always. Here, pretend each user picked a theme, and Ana picked dark. So ThemeProvider reads the user with useUser().

That makes the order matter. A provider can read another provider’s context only from inside it. Press Edit and change the order: put <ThemeProvider> outside and <UserProvider> inside. Run it, and the app stops with useUser must be used inside a UserProvider. Part 23 explained why: a component sees only the providers above it.

AppProviders keeps that order in one place, where you can read it from top to bottom. Change name="Ana" to name="Ben", and the page says “Hello, Ben. Your theme is light.”

Where to put a provider

Keep the state in its own component

Here is a search box. The text you type is in context, so the results can read it. But the state and the context are in App, and App also writes the whole page:

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

const QueryContext = createContext('')

const fruits = ['Apple', 'Banana', 'Cherry', 'Mango']

function Header() {
  console.log('Header renders')
  return <h1>Fruit shop</h1>
}

function Results() {
  const query = useContext(QueryContext)
  console.log('Results renders')
  const shown = fruits.filter(f => f.toLowerCase().includes(query.toLowerCase()))
  return <p>Found: {shown.join(', ')}</p>
}

function Footer() {
  console.log('Footer renders')
  return <p>Open every day.</p>
}

export default function App() {
  const [query, setQuery] = useState('')
  return (
    <QueryContext value={query}>
      <Header />
      <input aria-label="Search" value={query} onChange={e => setQuery(e.target.value)} />
      <Results />
      <Footer />
    </QueryContext>
  )
}
Header renders
Header renders
Results renders
Results renders
Footer renders
Footer renders
Header renders
Header renders
Results renders
Results renders
Footer renders
Footer renders

toLowerCase() makes the text small letters, so m finds Mango.

Run it and type the letter “m”. All three components render again. When you type, the state in App changes, and App writes <Header /> and <Footer /> itself. So they are new elements on every key. The context doesn’t help them at all. With Strict Mode off, typing “an” (two keys) rendered Header, Results and Footer 2 times each.

Search text changes on every key. Part 23 warned about this: “The text in a search box changes on every key press”. It said to think about who reads such a value. And it said to keep the context small. So we follow that here. Only two components will read the text. And the provider will be small, and placed low: a component of its own, SearchProvider, around only the search:

import { createContext, useContext, useState, type ReactNode } from 'react'

const QueryContext = createContext('')
const SetQueryContext = createContext<(query: string) => void>(() => {})

const fruits = ['Apple', 'Banana', 'Cherry', 'Mango']

function SearchProvider({ children }: { children: ReactNode }) {
  const [query, setQuery] = useState('')
  return (
    <QueryContext value={query}>
      <SetQueryContext value={setQuery}>{children}</SetQueryContext>
    </QueryContext>
  )
}

function Header() {
  console.log('Header renders')
  return <h1>Fruit shop</h1>
}

function SearchBox() {
  const query = useContext(QueryContext)
  const setQuery = useContext(SetQueryContext)
  console.log('SearchBox renders')
  return <input aria-label="Search" value={query} onChange={e => setQuery(e.target.value)} />
}

function Results() {
  const query = useContext(QueryContext)
  console.log('Results renders')
  const shown = fruits.filter(f => f.toLowerCase().includes(query.toLowerCase()))
  return <p>Found: {shown.join(', ')}</p>
}

function Footer() {
  console.log('Footer renders')
  return <p>Open every day.</p>
}

export default function App() {
  return (
    <>
      <Header />
      <SearchProvider>
        <SearchBox />
        <Results />
      </SearchProvider>
      <Footer />
    </>
  )
}
Header renders
Header renders
SearchBox renders
SearchBox renders
Results renders
Results renders
Footer renders
Footer renders
SearchBox renders
SearchBox renders
Results renders
Results renders

Run it and type “m”. Now only SearchBox and Results render again. They are the two readers. The input moved into its own component, SearchBox, because it needs the text and the setter.

We counted renders while typing “an”, with Strict Mode off:

Where the search state is Header SearchBox or the input Results Footer
In App, which writes the whole page 2 (in App) 2 2
In SearchProvider, around the search only 0 2 2 0
In SearchProvider, around the whole page 0 2 2 0

As low as it can go

Look at the last row. We also put SearchProvider around the whole page, with Header and Footer inside it. They still rendered 0 times, because they came in as children. So a high provider is not slow just because it is high.

Still, put a provider as low as it can go: just above the components that need it. Wrapping less is better for a few reasons.

  • Fewer components can read it. Any component inside a provider can call its hook. When the state changes often, that is easy to get wrong. The mistake Reading context where you don’t need it shows how.
  • The state goes away with its part of the page. If the search panel is removed, the provider inside it is removed too, and its state with it. Part 18 explained that state lives at a place in the tree.
  • Each copy gets its own state. Put two <CartProvider>s side by side, and each has a cart of its own. Each reader uses the nearest provider above it, as Part 23 showed. Practice question 4 tries it.

Theme or language often goes at the top, because the whole app reads it and it rarely changes. A cart, a search or a form usually goes lower.

Common mistakes

Making a new value object on every render

import { createContext, useState, type ReactNode } from 'react'

type Theme = { color: string; toggle: () => void }
const ThemeContext = createContext<Theme | null>(null)

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [color, setColor] = useState('light')
  const toggle = () => setColor(c => (c === 'light' ? 'dark' : 'light'))
  return <ThemeContext value={{ color, toggle }}>{children}</ThemeContext>
}

{{ color, toggle }} is a new object on every render of ThemeProvider, with a new toggle inside. If the provider’s parent renders often, every reader renders with it. In the theme test, with memo on Page, ThemeButton rendered 3 times (Strict Mode off). The 3 clicks didn’t change the theme. The fix is useMemo around the object, with the function made inside it, as in Part 23’s Fix 1. Do it when the parent renders often, not by habit. With the React Compiler, you may not need to write it.

One context for the state and the actions

import { createContext, useReducer, type Dispatch, type ReactNode } from 'react'

type Action = { type: 'added'; name: string }
type CartValue = { cart: string[]; dispatch: Dispatch<Action> }
const CartContext = createContext<CartValue | null>(null)

function cartReducer(cart: string[], action: Action) {
  return [...cart, action.name]
}

export function CartProvider({ children }: { children: ReactNode }) {
  const [cart, dispatch] = useReducer(cartReducer, [])
  return <CartContext value={{ cart, dispatch }}>{children}</CartContext>
}

value={{ cart, dispatch }} makes every component that only dispatches render whenever the cart changes. useMemo can’t fix this one, because the cart is in the object. In our test, AddButton rendered 3 times after 3 clicks, with or without useMemo (Strict Mode off). Use two contexts: one for the state, one for dispatch.

Keeping fast-changing state in a component that writes everything

The wrong shape is App with const [query, setQuery] = useState('') at the top, writing <Header />, the input, <Results /> and <Footer /> itself. Then the whole page renders on every change. That was the first search example: Header and Footer rendered on every key, 2 times for “an” (Strict Mode off). Move the state into a provider component that takes children, and wrap only the part that needs it.

Reading context where you don’t need it

Here a Layout component reads the search text only to show one line, “Searching for: m”:

import { createContext, useContext, useState, type ReactNode } from 'react'

const QueryContext = createContext('')
const SetQueryContext = createContext<(query: string) => void>(() => {})

const fruits = ['Apple', 'Banana', 'Cherry', 'Mango']

function SearchProvider({ children }: { children: ReactNode }) {
  const [query, setQuery] = useState('')
  return (
    <QueryContext value={query}>
      <SetQueryContext value={setQuery}>{children}</SetQueryContext>
    </QueryContext>
  )
}

function Header() {
  console.log('Header renders')
  return <h1>Fruit shop</h1>
}

function SearchBox() {
  const query = useContext(QueryContext)
  const setQuery = useContext(SetQueryContext)
  console.log('SearchBox renders')
  return <input aria-label="Search" value={query} onChange={e => setQuery(e.target.value)} />
}

function Results() {
  const query = useContext(QueryContext)
  console.log('Results renders')
  const shown = fruits.filter(f => f.toLowerCase().includes(query.toLowerCase()))
  return <p>Found: {shown.join(', ')}</p>
}

function Footer() {
  console.log('Footer renders')
  return <p>Open every day.</p>
}

function Layout() {
  const query = useContext(QueryContext)
  console.log('Layout renders')
  return (
    <div>
      <Header />
      <p>Searching for: {query}</p>
      <SearchBox />
      <Results />
      <Footer />
    </div>
  )
}

export default function App() {
  return (
    <SearchProvider>
      <Layout />
    </SearchProvider>
  )
}

Run it and type “m”. The Console shows all five components again. Layout reads the context, so it renders on every key. And Layout writes <Header /> and <Footer /> itself, so they render too. With Strict Mode off, typing “an” rendered all five 2 times each.

The fix: move the read into a small component, SearchLabel, that shows only that line. Layout then writes <SearchLabel /> in place of the <p>, and reads no context:

import { createContext, useContext, useState, type ReactNode } from 'react'

const QueryContext = createContext('')
const SetQueryContext = createContext<(query: string) => void>(() => {})

const fruits = ['Apple', 'Banana', 'Cherry', 'Mango']

function SearchProvider({ children }: { children: ReactNode }) {
  const [query, setQuery] = useState('')
  return (
    <QueryContext value={query}>
      <SetQueryContext value={setQuery}>{children}</SetQueryContext>
    </QueryContext>
  )
}

function Header() {
  console.log('Header renders')
  return <h1>Fruit shop</h1>
}

function SearchBox() {
  const query = useContext(QueryContext)
  const setQuery = useContext(SetQueryContext)
  console.log('SearchBox renders')
  return <input aria-label="Search" value={query} onChange={e => setQuery(e.target.value)} />
}

function Results() {
  const query = useContext(QueryContext)
  console.log('Results renders')
  const shown = fruits.filter(f => f.toLowerCase().includes(query.toLowerCase()))
  return <p>Found: {shown.join(', ')}</p>
}

function Footer() {
  console.log('Footer renders')
  return <p>Open every day.</p>
}

function SearchLabel() {
  const query = useContext(QueryContext)
  return <p>Searching for: {query}</p>
}

function Layout() {
  console.log('Layout renders')
  return (
    <div>
      <Header />
      <SearchLabel />
      <SearchBox />
      <Results />
      <Footer />
    </div>
  )
}

export default function App() {
  return (
    <SearchProvider>
      <Layout />
    </SearchProvider>
  )
}
Layout renders
Layout renders
Header renders
Header renders
SearchBox renders
SearchBox renders
Results renders
Results renders
Footer renders
Footer renders
SearchBox renders
SearchBox renders
Results renders
Results renders

Run it and type “m”. Now only SearchBox and Results log. With Strict Mode off, typing “an” rendered Layout, Header and Footer 0 times, and SearchBox and Results 2 times each. SearchLabel doesn’t log, but it renders too: the line on the page changes. It is a reader now.

Practice

Press Edit on “Try this first” and try these. Strict Mode is on in the playground, so every render logs twice.

  1. Add a second <AddButton name="Bread" /> under the first one. Run it and click “Add Bread” two times. How many AddButton renders lines does the Console show in all?
  2. Add an EmptyButton component. It reads CartDispatchContext and dispatches { type: 'emptied' }. Put it inside the provider. Click “Add Apple” twice, then press your new button. Which components log after that last press?
  3. Move <CartSummary /> out of the provider: below </CartProvider>, with a Fragment around both. Click “Add Apple” twice. What does the summary show? Why is there no error?
  4. Make App show <Header /> and then two providers side by side. The first has <AddButton name="Apple" /> and <CartSummary />. The second has <AddButton name="Bread" /> and <CartSummary />. Click “Add Apple” twice. What do the two summaries show?

Answers

  1. Four lines, all from the first render: two buttons, and Strict Mode calls each twice. The clicks add none. Each click logs CartProvider and CartSummary, twice each. The summary shows “In the cart: 2”.
  2. CartProvider renders twice, then CartSummary renders twice. EmptyButton doesn’t log, because dispatch didn’t change. The summary shows “In the cart: 0”. The button is at the end of these answers.
  3. It shows “In the cart: 0” and never changes. CartSummary has no CartProvider above it, so useContext gives the default value, the empty array []. That is why the hooks in “Custom hooks” use null and throw. Then this mistake shows up as an error. The clicks still change the cart inside the provider. The Console logs CartProvider renders twice per click, but nobody reads it.
  4. The first shows “In the cart: 2” and the second shows “In the cart: 0”. Each CartProvider has its own reducer, so its own cart. Each CartSummary reads the nearest provider above it.

For question 2, add only the EmptyButton function to the app. The app already has the import, the Action type and CartDispatchContext. The block below repeats them only so it can be checked on its own:

import { createContext, useContext, type Dispatch } from 'react'

type Action = { type: 'added'; name: string } | { type: 'emptied' }
const CartDispatchContext = createContext<Dispatch<Action>>(() => {})

export function EmptyButton() {
  const dispatch = useContext(CartDispatchContext)
  console.log('EmptyButton renders')
  return <button onClick={() => dispatch({ type: 'emptied' })}>Empty the cart</button>
}

Interview questions

Try to answer each one out loud before you open the answer.

What is a provider component?

A component that shares a value (usually state it owns) through context and wraps its children. For example, CartProvider calls useReducer, gives the cart and dispatch to two contexts, and renders {children} inside them. The rest of the app wraps a part of the page in <CartProvider>. Any component inside can then read or change the cart with no props.

A strong answer adds that it usually comes with custom hooks, like useCart(). The contexts themselves are often not exported. React’s docs show this shape in “Scaling Up with Reducer and Context”.

Why does a provider take children, and not write the components itself?

So that a change in the provider’s state doesn’t render everything inside it. The elements in children were made by the parent. When only the provider renders, children is the same object as before. So React skips those components, unless they read the context. In our test, with Strict Mode off and 3 clicks, a component that read nothing rendered 0 times with children. When the provider wrote it itself, it rendered 3 times.

Why split state and dispatch into two contexts?

They change at different times. The state changes on every action. dispatch never changes: React’s docs say it “has a stable identity”. If both are in one object, the object is new on every action. So a component that only dispatches renders too. With two contexts, it reads only dispatch and stays still. useMemo on a combined object doesn’t fix it, because the state is one of its dependencies.

A strong answer adds this. The same idea works with useState. Share the value and the set function in two contexts, as Part 23 did in Fix 2. The set function never changes either, as Part 4 showed.

When does useMemo on a context value help?

When the provider can render while its value stays the same. For example, when its parent renders. Without useMemo, the value is a new object each time, and every reader renders. With useMemo, the object stays the same until something in it changes.

It helps only if something stops the render on the way down, like memo or children. Otherwise the readers render anyway, because their parents rendered. A strong answer says it is a performance fix, not a rule. React’s docs say “In smaller apps, this is not a problem.” The answer also names the React Compiler. It can do this for you. We built our theme app with it, and it kept the value object by itself until the theme changed. The playground and the Part 10 project don’t use the compiler.

Why wrap useContext in a custom hook?

Three reasons. It can throw a clear error when there’s no provider above, if the context’s default is null. Otherwise the component quietly gets the default value. It gives a type with no null in it. And the components don’t depend on how the context is built. So you can change it later, in one place.

Where should a provider go?

Just above the components that need it, as low as it can go. Then fewer components can read it by mistake. Its state goes away with its part of the page. And each copy has its own state. A provider that takes children is not slow just for being high: only its readers render. But state that changes often should not sit in a component that also writes the whole page. Something the whole app reads and that rarely changes, like a theme, can go at the top.

How do you handle many providers?

Put them in one component, like AppProviders, that nests them in the right order and takes children. The order matters when one provider reads another’s context: the one it reads must be outside. A strong answer adds a tip. If a provider’s hook throws “must be used inside”, check the order in that one place.

Is reducer plus context the same as a state library like Redux?

Not quite. Both keep state in one place and change it with actions. But with context, every component that reads the state renders when any part of it changes. React’s docs say it “re-renders all the children that use a particular context”. A library like Redux lets a component read one part and render only when that part changes. Part 25 shows ways to get close to that with context.

A strong answer knows how. React Redux’s useSelector keeps the state outside React and reads it with useSyncExternalStoreWithSelector. That comes from a small package built on React’s own useSyncExternalStore hook. Redux also adds middleware, which is code that sees every action. And it has the Redux DevTools, which show each action as it happens.

Sources

  • Scaling Up with Reducer and Context, react.dev: the three steps, “two separate contexts”, the TasksProvider that will “tie all the pieces together”, the custom hooks, and “later split these contexts further or add some logic to these functions”.
  • useContext, react.dev: “re-renders all the children that use a particular context”, the Object.is compare, and “Optimizing re-renders when passing objects and functions”, with “In smaller apps, this is not a problem” and “This is a performance optimization”.
  • createContext, react.dev: rendering <SomeContext> as a provider in React 19, and the default value used when no provider is above.
  • Passing Data Deeply with Context, react.dev: managing state as a use case for context.
  • useReducer, react.dev: “The dispatch function has a stable identity”.
  • StrictMode, react.dev: why components render twice in development.
  • useSyncExternalStore, react.dev: the hook for “Third-party state management libraries that hold state outside of React”.
  • React Redux 9.3.0’s dist/react-redux.mjs and its package.json: useSelector calls useSyncExternalStoreWithSelector from use-sync-external-store, whose code calls React.useSyncExternalStore.
  • Redux Fundamentals, Part 5: UI and React, redux.js.org: “enhancers and middleware” and “the Redux DevTools to let us see what’s happening inside our app as actions are dispatched”.
  • The @vitejs/plugin-react 6.1.2 README, section “Consistent components exports”: “For React refresh to work correctly, your file should only export React components.” and “the module will be invalidated and HMR will propagate”.
  • The render counts, the logs and the error messages above come from running React 19.3.0 for this post. The compiler result comes from babel-plugin-react-compiler 1.0.0. The lint result comes from Oxlint 1.87.0 with the Part 10 project’s settings, and the three-file version was checked with TypeScript.
  • This part follows the Provider Pattern kata in react-katas. The kata counts renders by changing a ref while the component renders. The useRef page says “Do not write or read ref.current during rendering”, so we count with console.log instead.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.