Skip to content

Context

Lifting state up works well when the components sharing a value sit close together. It gets painful when they don't. A value that lives near the top of the tree and is needed six levels down has to be passed through every component in between, and each of those components has to accept and forward a prop it never uses. That relay is prop drilling, the problem Compound components ran into and could not solve with Children.map. Context is React's built-in way out of it: one component provides a value, and any component beneath it can read that value directly, no matter how many layers sit in between.

Think of it as teleporting the data. The components in the middle never see it and never forward it; their prop lists stay about their own jobs.

Creating a context and providing a value

Context has three parts: create it, provide a value, read the value. Creating it happens once, at the top level of a file, outside any component. You'll usually export it too, because the components that read it often live in other files.

jsx
import { createContext } from 'react'

export const ThemeContext = createContext()

export default function App() {
  return (
    <ThemeContext value="light">
      <Header />
      <Button />
    </ThemeContext>
  )
}

Rendering the context object itself as a component makes everything inside it a consumer-in-waiting. The value prop is the payload: here it's the string "light", but it can be any JavaScript value, an object, an array, even a function. Whatever you put there is what readers below will receive. The prop must be spelled value; that name is part of the API.

Placement matters. The provider doesn't have to wrap your whole app, and usually it shouldn't. Put it around the smallest subtree that covers every component needing the value. If only one corner of the UI cares about the theme, provide the theme at that corner's common ancestor and leave the rest of the tree out of it.

Version note

Before React 19 a context couldn't be rendered directly; you rendered <ThemeContext.Provider value="light"> instead. React 19 made the context object itself renderable as the provider. The .Provider form still works and is what you'll see in most existing codebases and in the course lessons, but React plans to deprecate it in a future version and ships a codemod for the switch, so reach for the bare <ThemeContext> form in new code.

Reading the value with useContext

Any component under the provider reads the value with useContext, the hook first introduced in Hooks, passing it the context object so React knows which context to read. That's why the context gets exported.

jsx
import { useContext } from 'react'
import { ThemeContext } from './App'

function Header() {
  const theme = useContext(ThemeContext)

  return (
    <header className={`${theme}-theme`}>
      <h1>{theme === 'light' ? 'Light' : 'Dark'} Theme</h1>
    </header>
  )
}

useContext(ThemeContext) returns whatever the nearest ThemeContext provider above this component is currently holding: here, the string "light". There's no prop threading between App and Header, and components between them in a larger tree wouldn't mention the theme at all. An app can have several contexts at once, each created separately and each read by passing its own object to useContext.

Making it live: state plus context

A hard-coded value="light" never changes. The pattern that makes context useful pairs it with state: state owns the value and updates it, context delivers it. Pass an object holding both the current value and a function that changes it, and every consumer can read or update the theme from anywhere below the provider.

jsx
export const ThemeContext = createContext()

export default function App() {
  const [theme, setTheme] = useState('light')

  function toggleTheme() {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
  }

  return (
    <ThemeContext value={{ theme, toggleTheme }}>
      <Header />
      <Button />
    </ThemeContext>
  )
}

function Button() {
  const { theme, toggleTheme } = useContext(ThemeContext)

  return (
    <button className={`${theme}-theme`} onClick={toggleTheme}>
      Switch theme
    </button>
  )
}

When the button is clicked, toggleTheme runs, the state in App updates, App re-renders, and the provider passes the new object down. Every component reading the context re-renders with the fresh value. Consumers destructure only the properties they need.

This scoping trick also works in the small. A component like a dropdown menu can render a provider around its own children, giving the pieces inside it a shared state that never leaks to the rest of the page. Here is the menu from Compound components, finished:

jsx
const MenuContext = createContext()

function Menu({ children }) {
  const [open, setOpen] = useState(false)
  const toggle = () => setOpen(prevOpen => !prevOpen)

  return <MenuContext value={{ open, toggle }}>{children}</MenuContext>
}

function MenuButton({ children }) {
  const { toggle } = useContext(MenuContext)

  return <button onClick={toggle}>{children}</button>
}

Menu owns open and puts both it and toggle on the context; MenuButton reaches up for the toggle with useContext, and a MenuDropdown written the same way reads open to decide whether to render. Nothing in between relays a thing, so a caller can wrap the button in as many layout divs as they like and the menu keeps working.

Exporting the raw context works, but most codebases wrap the pattern in two pieces: a provider component that owns the state, and a custom hook that reads the context and fails loudly when misused.

jsx
const ThemeContext = createContext(null)

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')

  function toggleTheme() {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
  }

  return (
    <ThemeContext value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext>
  )
}

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

Now consumers write const { theme } = useTheme() and never import the context object at all, which keeps the module's public surface to exactly two things, the provider and the hook. The argument to createContext is the fallback React hands back when no provider sits above the consumer, so null here means "nobody provided this," and that is what the check tests for. Without it the consumer crashes while destructuring a null value, and the stack trace points at the consumer rather than at the missing provider.

When a provider's value changes, React re-renders every component that reads that context, however deep it sits. "Changes" means a failed Object.is comparison, which is where the object-literal value bites: the object passed to value in the code above builds a fresh object on every render of the provider's owner, so every render of that owner re-renders every consumer, whether or not theme actually changed.

When the owner re-renders only because the value changed, as App does here, memoizing buys nothing. It pays off when the owner re-renders for unrelated reasons and drags every consumer along with it. Wrapping the object in useMemo is half the fix: toggleTheme is a fresh function on every render, so the memo recomputes every time unless that function is stabilized too, with useCallback or by passing the already-stable setTheme in place of a wrapper.

Two structural tools matter more than memoization. First, split contexts along update frequency: a value that changes once per session and a value that changes on every keystroke don't belong in the same provider, because the fast one drags the slow one's consumers along with it.

Second, remember that context is a delivery mechanism rather than a state manager. It solves "this value is needed far away." It does nothing about how updates are batched, derived, or persisted. When an app's shared state grows past a theme and a user object, that's the point where a dedicated store like Redux Toolkit or Zustand earns its keep.

How each store reaches your components

The two deliver their stores differently: a Redux Toolkit app wraps the tree in <Provider store={store}> from react-redux, which hands the store to hooks below it through context, while Zustand's default setup exports a module-level hook that any component calls directly, with no provider involved.

JunoContext skips the middle layers When a value has to travel far down the tree, passing it through every component gets exhausting fast.

Context lets one component near the top say "here is the value" and lets any component below reach up and grab it directly with useContext. The components in the middle never touch it.

Pair it with state and the value can change too: keep the value and its update function together in the provider, and any consumer can read or change it.

JunoContext skips the middle layers Create a context at module level, render it as a provider with a value, and read it below with useContext. Make it live by passing state and its updater together in one object.

In real code, wrap the provider in its own component and expose a useTheme-style hook that throws outside the provider; consumers get a clean API and mistakes surface with a readable error.

JunoContext skips the middle layers Every consumer re-renders when the provided value fails Object.is, and an inline object literal fails it on every provider render, so memoize the value where update frequency makes that matter. Split contexts that update at different rates, and scope providers to the smallest subtree that needs them.

Treat context as delivery for distant values; once shared state needs real management, reach for a store, whether it rides on context like Redux Toolkit or skips it like Zustand.

Next up: Render props and headless components, where a component provides behavior and lets you supply the markup.