In one minute
Prop drilling means passing a value through layers of components that don’t use it, only to reach one that does. Context solves it in three steps:
createContext(defaultValue)declares something that can be shared.<MyContext value={…}>provides a value to everything below it.- Any component below reads it with
use(MyContext).
When the provided value changes, React re-renders the components that read it, even if the components in between are memoized. It decides “changed” with Object.is, so a provider value written as a new object on every render counts as a change on every render.
You’ll be able to: share a theme and a cart through context, give consumers a clear error when the provider is missing, and keep context from re-rendering more than it should.
1const ThemeContext = createContext('light')2const CartContext = createContext(null)34function CartProvider({ children }) {5 const [count, setCount] = useState(0)6 const value = useMemo(() => ({ count, add }), [count])7 return <CartContext value={value}>{children}</CartContext>8}910function ProductCard({ name }) {11 const theme = use(ThemeContext)12 return <article className={theme}>{name}</article>13}1415function CartBadge() {16 const { count } = use(CartContext)17 return <span>{count} in cart</span>18}- eventClick the theme toggle. The theme state lives in
App, soApprenders and passes"dark"toThemeContext. - render phase
const value = useMemo(() => ({ count, add }), [count])CartProvideris a child ofApp, so it renders too. ItsuseMemoreturns the same value object, becausecountdidn’t change. - render phase
const theme = use(ThemeContext)HeaderandProductGridare memoized and got no new props, so they’re skipped. React doesn’t need them: it finds the components that read the changed context and renders those directly. - commit phaseRecorded:
App,CartProviderand the three cards. The cart readers didn’t render: their context didn’t change.
1const ThemeContext = createContext('light')2const CartContext = createContext(null)34function CartProvider({ children }) {5 const [count, setCount] = useState(0)6 const value = { count, add } // new object each render7 return <CartContext value={value}>{children}</CartContext>8}910function ProductCard({ name }) {11 const theme = use(ThemeContext)12 return <article className={theme}>{name}</article>13}1415function CartBadge() {16 const { count } = use(CartContext)17 return <span>{count} in cart</span>18}- eventThe same click on the theme toggle.
- render phase
const value = { count, add } // new object each renderCartProviderrenders and builds its value inline: a new object every time, althoughcountis still 0. - render phase
const { count } = use(CartContext)React compares context values withObject.is. A new object counts as a change, so every cart reader renders as well. - commit phaseRecorded: also
CartBadgeandAddButton, for a change that had nothing to do with the cart.
1const ThemeContext = createContext('light')2const CartContext = createContext(null)34function CartProvider({ children }) {5 const [count, setCount] = useState(0)6 const value = useMemo(() => ({ count, add }), [count])7 return <CartContext value={value}>{children}</CartContext>8}910function ProductCard({ name }) {11 const theme = use(ThemeContext)12 return <article className={theme}>{name}</article>13}1415function CartBadge() {16 const { count } = use(CartContext)17 return <span>{count} in cart</span>18}- eventClick Add to cart. The cart state lives in
CartProvider. - render phase
const [count, setCount] = useState(0)OnlyCartProviderrenders;Appis above it and isn’t involved. Its value is new, becausecountchanged. - render phase
const { count } = use(CartContext)The two cart readers render. The cards (theme only) and the memoized parents don’t. - commit phaseRecorded:
CartProvider,CartBadge,AddButton.
The example: theme and cart
The store has two shared values. The theme is read by every product card. The cart is read by the header badge and the “Add to cart” button. Without context, App would pass theme through ProductGrid, and the cart through Header and ProductGrid, though neither of them uses it.
1. Create the contexts
type Theme = 'light' | 'dark'
const ThemeContext = createContext<Theme>('light') // 'light' is used when no provider is above
type CartValue = { count: number; add: () => void }
const CartContext = createContext<CartValue | null>(null)
function useCart() {
const value = use(CartContext)
if (value === null) throw new Error('useCart must be used inside <CartProvider>')
return value
}
CartContext defaults to null, meaning “no provider”. The useCart hook turns that into a clear error and narrows the type from CartValue | null to CartValue for every caller.
2. Provide the values
function CartProvider({ children }: { children: ReactNode }) {
const [count, setCount] = useState(0)
const value = useMemo(() => ({ count, add: () => setCount((c) => c + 1) }), [count])
log('render CartProvider')
return <CartContext value={value}>{children}</CartContext>
}
function App({ Provider = CartProvider }) {
const [theme, setTheme] = useState<Theme>('light')
log('render App')
return (
<ThemeContext value={theme}>
<Provider>
<button id="theme" onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}>
Toggle theme
</button>
<Header />
<ProductGrid />
</Provider>
</ThemeContext>
)
}
Since React 19 you render the context itself as the provider: <ThemeContext value={…}>. The older <ThemeContext.Provider value={…}> still works.
3. Read them
function CartBadge() {
const { count } = useCart()
log('render CartBadge')
return <span id="badge">{count} in cart</span>
}
function ProductCard({ name, onSale }: { name: string; onSale: boolean }) {
const theme = use(ThemeContext)
log(`render ProductCard (${name})`)
return (
<article className={`product-card ${theme}`}>
<h2>{name}</h2>
<SaleTag onSale={onSale} />
</article>
)
}
function SaleTag({ onSale }: { onSale: boolean }) {
if (!onSale) return null
const theme = use(ThemeContext) // use() may be called after an early return
return <span className={`tag tag--sale ${theme}`}>Sale</span>
}
SaleTag calls use after an early return. That’s allowed for use and not for any hook, including useContext.
Who re-rendered
Toggling the theme:
render App
render CartProvider
render ProductCard (Ceramic Mug)
render ProductCard (Wireless Headphones)
render ProductCard (Trail Backpack)
Header and ProductGrid are memo components with no changed props, so they were skipped. React still reached the three ProductCards below ProductGrid, because they read the theme. CartBadge and AddButton didn’t render: the cart value is memoized and didn’t change.
Clicking “Add to cart”:
render CartProvider
render CartBadge
render AddButton
Now the same toggle with a provider that builds its value inline:
const value = { count, add: () => setCount((c) => c + 1) } // a new object every render
render App
render CartProvider
render CartBadge
render ProductCard (Ceramic Mug)
render ProductCard (Wireless Headphones)
render ProductCard (Trail Backpack)
render AddButton
CartBadge and AddButton rendered too. The theme toggle re-rendered CartProvider, which built a new object with the same contents, and React treats a new object as a new value.
No provider
<ProductCard name="Desk Lamp" onSale />
<ErrorBoundary fallbackRender={({ error }) => <p role="alert">{(error as Error).message}</p>}>
<CartBadge />
</ErrorBoundary>
What rendered:
{
"logs": [
"render ProductCard (Desk Lamp)",
"render ProductCard (Desk Lamp)"
],
"warnings": [
"Error: useCart must be used inside <CartProvider>"
],
"article": "product-card light",
"sale": "tag tag--sale light",
"alert": "useCart must be used inside <CartProvider>"
}
The card fell back to ThemeContext’s default, 'light'. The badge hit useCart’s check, and the error boundary showed the message instead of a confusing Cannot read properties of null.
How it works
- A provider sets a value for a subtree.
use(Ctx)returns the value from the nearest provider above, or the default fromcreateContextif there’s none. Providers can be nested, and an inner one overrides an outer one. - Changes go straight to readers. When a provider’s value changes, React walks down from the provider and marks every component that reads that context (
propagateContextChangesin the React Engine Map). Readers inside a memoized, skipped parent are still rendered. You can’t block a context update withmemo. - Changes are detected with
Object.is. For object values, memoize the object (useMemo) and its functions (useCallback, or asetStateupdater as inCartProvider), so the value only changes when its contents do. usevsuseContext. Both read context.usecan also be called conditionally and in loops, and it can read promises (see the Suspense notes). New code can useuseeverywhere.- Context is for values that are read widely and change rarely: theme, signed-in user, locale, feature flags. A value that changes on every keystroke and has many readers re-renders all of them each time. For that, split the context, or use a store with selectors (useSyncExternalStore).
Common mistakes
- An inline object as the value (
value={{ count, add }}). Every render of the provider’s parent re-renders every reader (recorded above). - One big context for everything. A theme change re-renders cart readers, and a cart change re-renders theme readers. Split contexts by how often they change.
- Reading a nullable context without a check. Wrap it in a hook that throws a clear error.
- Using context to avoid passing props one or two levels. Passing props is clearer. Composition (passing JSX as
children) often removes drilling without context.
Interview Q&A
What problem does context solve?
Prop drilling: passing a value through components that don’t use it so a deep descendant can. With context, any component below a provider reads the value directly.
Walk through the three pieces of the context API.
createContext(default) creates the context. <Ctx value={v}> (React 19; <Ctx.Provider> before that) provides a value to its subtree. use(Ctx) or useContext(Ctx) reads the nearest provider’s value, or the default if there’s none.
What's the difference between use and useContext?
They read context the same way. use isn’t bound by the rules of hooks, so it can run inside conditions, loops or after an early return, and it can also unwrap a promise. useContext must be called at the top level every render and only reads context.
When a context value changes, which components re-render?
Every component that reads that context, wherever it is below the provider, even inside memoized parents. Recorded: toggling the theme rendered the three ProductCards, while the memoized Header and ProductGrid were skipped.
Why can an inline provider value hurt performance?
Context changes are detected with Object.is, and { count, add } is a new object on every render. Recorded: with the inline value, a theme toggle also rendered CartBadge and AddButton, although the cart hadn’t changed. useMemo fixed it.
How do you make a missing provider fail loudly?
Default the context to null and read it through a custom hook that throws if the value is null. Recorded: “useCart must be used inside <CartProvider>”, shown by the error boundary.
When is context the wrong tool?
For values that change very often and are read by many components (every reader re-renders each time). Use a store with selectors, or split the context.
Related
- useReducer: share
dispatchthrough context to give distant components the cart actions. - React Re-rendering:
memo,useMemoandObject.is. - useSyncExternalStore: a store outside React, for frequently changing shared state.
- React Engine Map:
readContextandpropagateContextChanges, how React finds context readers.
Sources
- react.dev:
createContext,use,useContext, Passing data deeply with context - Topic order inspired by Kent C. Dodds’ EpicReact Advanced React APIs workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/createContext