React

The React Context API: Shared State Without a Library

Learn how to use React's built-in Context API to share state across components without prop drilling or reaching for a third-party library.

At some point in every React project, you hit the wall. A piece of state lives at the top of your component tree, and you need it three levels down. You start passing props through components that don't use them, just to get the data where it needs to go. The code still works, but something feels off.

That is prop drilling, and Context is the built-in answer to it.

I reached for third-party state libraries before I really understood Context. Once I slowed down and learned how it works, I found it covers a lot of ground on its own — theme toggles, logged-in user info, locale preferences, and other cross-cutting state that many components need but few components actually change.

What Context Actually Is

Context is a way to broadcast a value to any component in a subtree without passing it as a prop at every level. The component that holds the value is the provider. Any component below it in the tree can read that value through a consumer.

The React docs describe it as a way to avoid prop drilling for "global" data, but I'd put a softer boundary on that word. Context is for data that is shared across a subtree, not necessarily the entire app. You can have multiple contexts covering different scopes.

Setting Up Context with TypeScript

Here is a minimal theme context. I'll build it up step by step so each piece is clear.

First, create the context:

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

type Theme = "light" | "dark";

type ThemeContextValue = {
  theme: Theme;
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextValue | null>(null);

I initialize it with null rather than a default value. This means any component that tries to read the context outside of a provider will get null, which is a clear signal that something is wired up wrong — better than getting a silent stale default.

Next, the provider component:

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");

  function toggleTheme() {
    setTheme((t) => (t === "light" ? "dark" : "light"));
  }

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

This is a plain component. State lives here. The value prop is what gets broadcast to all consumers below.

Finally, a custom hook that reads from the context:

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

The error throw is the payoff from initializing with null. If you call useTheme() outside a <ThemeProvider>, you get an immediate, informative error instead of a confusing runtime failure later.

Using It in the Tree

Wrap whatever subtree needs access to the theme:

// app/layout.tsx or wherever your root is
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Any component anywhere in that tree can now call useTheme() directly:

function Header() {
  const { theme, toggleTheme } = useTheme();

  return (
    <header data-theme={theme}>
      <button onClick={toggleTheme}>
        {theme === "light" ? "Switch to dark" : "Switch to light"}
      </button>
    </header>
  );
}

No prop passing. Header pulls the value it needs and nothing else.

What Context Is Not Good At

Context re-renders every component that consumes it whenever the provided value changes. If the value is an object, a new object reference on each render triggers all consumers — even if the actual data is identical.

// This creates a new object on every render — all consumers re-render
<ThemeContext.Provider value={{ theme, toggleTheme }}>

For the theme example, this is fine. Toggles are rare. But if you put fast-changing data — like a form field updating on every keystroke — into context, you will see performance issues.

The fix is to stabilize the value with useMemo and useCallback:

const toggleTheme = useCallback(() => {
  setTheme((t) => (t === "light" ? "dark" : "light"));
}, []);

const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);

return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;

For slow-changing state like themes and user data, you often do not need this. For anything that updates often, you do.

When to Reach for Something Else

Context is a good fit when:

  • The state changes infrequently
  • Many components across the tree need read access
  • The logic is simple enough to live in a single provider

It becomes awkward when:

  • You need fine-grained subscriptions (only re-render when a specific slice of state changes)
  • State logic is complex enough to warrant a reducer — though you can combine Context with useReducer and it works well
  • Performance is critical and the state updates frequently

For those cases, Zustand or Jotai are worth looking at. Both are small, and Zustand in particular feels like a thin wrapper over what Context already gives you.

The Pattern I Use

Every context I write follows the same shape: one createContext call with a null default, one provider component holding the state, and one custom hook that throws if used outside the provider. It is a little boilerplate, but it is predictable — any developer on the team can read a new context file and immediately understand how it works.

Context is not glamorous, but it is already in React, it has no bundle cost, and for the right kind of state it is exactly the right tool.

← Older
The React Suspense Mental Model
Newer →
The Compound Component Pattern in React

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.