Skip to main content

Context

Context Patterns

Written by Published

Exporting the raw context object makes every consumer repeat the same three lines. Wrapping it in a provider component and a hook is the pattern almost every codebase converges on.

Provider plus hook

Two exports - a provider component that owns the state, and a hook that reads it and throws a useful error when used outside. Consumers never touch createContext or useContext directly.

The standard shape

jsx

const ThemeContext = React.createContext(null)

// 1. The provider owns the state.
function ThemeProvider({ children }) {
  const [theme, setTheme] = React.useState("light")
  const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light"))

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  )
}

// 2. The hook hides useContext and fails loudly if misused.
function useTheme() {
  const value = React.useContext(ThemeContext)
  if (value === null) throw new Error("useTheme must be used inside a ThemeProvider")
  return value
}

function Toolbar() {
  const { theme, toggle } = useTheme()
  return <button onClick={toggle}>Theme: {theme} (click to change)</button>
}

function App() {
  return (
    <ThemeProvider>
      <Toolbar />
    </ThemeProvider>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<App />)

The thrown error matters more than it looks. Without it, a component used outside the provider receives null and fails somewhere later with cannot read property of null - far from the actual mistake.

The new-object problem

value={{ theme, toggle }} creates a new object on every render of the provider. Context compares by identity, so every consumer re-renders even when nothing inside it changed.

Watch the consumer re-render

jsx

const Ctx = React.createContext(null)

function Consumer() {
  const value = React.useContext(Ctx)
  const renders = React.useRef(0)
  renders.current++
  return <p>Consumer rendered {renders.current} times (value: {value.label})</p>
}

function App() {
  const [tick, setTick] = React.useState(0)

  // A new object every render, even though label never changes.
  const value = { label: "stable text" }

  return (
    <Ctx.Provider value={value}>
      <button onClick={() => setTick(tick + 1)}>Re-render provider ({tick})</button>
      <Consumer />
      <p style={{ color: "#64748b" }}>
        The label never changes, yet the consumer re-renders every time.
      </p>
    </Ctx.Provider>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<App />)

The fix is useMemo around the value, so the object keeps its identity until its parts actually change. That is covered properly in the performance section - the point here is knowing why it is needed.

Splitting a context in two

When one context holds a value that changes often and a setter that never changes, every consumer of the setter re-renders whenever the value moves - even ones that only dispatch.

Splitting them into two contexts, one for the value and one for the updater, means a component that only needs to trigger changes never re-renders when the value changes. It is a real technique, and also a sign you may want a reducer.

  • Export a provider and a hook, not the context object.
  • Throw from the hook when there is no provider.
  • Memoise the value or every consumer re-renders.
  • Split value and updater when consumers need only one of them.