Skip to content
C Codeloom
React

React Context API: Sharing State Without Prop Drilling

A practical guide to React's Context API — createContext, Provider, and useContext — with the cases it solves cleanly and the cases where you should reach for a state library instead.

·9 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • How createContext, Provider, and useContext fit together
  • When Context is the right tool (theme, auth, locale, i18n)
  • When to reach for a state library like Zustand or Redux instead
  • How to avoid the most common Context performance pitfalls
  • A pattern for splitting one big context into focused contexts

Prerequisites

  • Comfort with hooks — see useState and useEffect
  • You have hit prop drilling at least once and felt the friction

You build a component tree. A value lives at the top — the current user, the theme, the language — and a leaf five levels down needs it. You start passing it through each layer as a prop. Every intermediate component now declares a prop it does not use. That is prop drilling, and the standard React fix for it is the Context API.

What Context actually is

Context is a way to make a value available to any component below a given point in the tree, without threading it through every intermediate component. You create a context, wrap part of the tree in its Provider, and any descendant can read the current value with useContext.

It is not a state manager. It is a delivery mechanism. The state still has to live somewhere — usually in useState or useReducer inside the provider component.

The three pieces

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

// 1. Create the context with a default value
const ThemeContext = createContext('light');

// 2. Wrap part of the tree in a Provider
function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={theme}>
      <Page />
    </ThemeContext.Provider>
  );
}

// 3. Read it anywhere below with useContext
function ThemedButton() {
  const theme = useContext(ThemeContext);
  return <button className={`btn-${theme}`}>Click</button>;
}

That is the whole API. Three steps: create, provide, consume.

The default value passed to createContext is only used when a consumer is rendered outside any matching Provider. In application code that is usually a bug; in tests it can be handy.

A real-world shape: an auth context

A typical context exposes both a value and the functions that update it:

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

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

  const login = useCallback(async (email, password) => {
    const res = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    const data = await res.json();
    setUser(data.user);
  }, []);

  const logout = useCallback(() => setUser(null), []);

  const value = { user, login, logout };
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

// Custom hook for consumers — friendlier than useContext directly
export function useAuth() {
  const ctx = useContext(AuthContext);
  if (ctx === null) {
    throw new Error('useAuth must be used inside <AuthProvider>');
  }
  return ctx;
}

Wrap the app once:

<AuthProvider>
  <App />
</AuthProvider>

Then anywhere in the tree:

function Header() {
  const { user, logout } = useAuth();
  return user
    ? <button onClick={logout}>Sign out {user.name}</button>
    : <a href="/login">Sign in</a>;
}

The header does not know or care where AuthProvider lives. It just asks.

When Context is the right tool

Context shines when a value is genuinely global to a subtree and changes infrequently:

  • Theme — light/dark, brand colour palette.
  • Authenticated user — id, name, roles, sign-in helpers.
  • Locale and translations — current language, message catalogues.
  • Routing primitives — the current route is shared via context by React Router.
  • Form state for a single formreact-hook-form uses context to share field registration.

Notice the pattern. These are all things that a wide swath of the tree wants to read, but that are updated by a small number of actions: signing in, switching theme, changing language. Low write frequency, broad read.

Try it yourself. Build a ThemeProvider that holds theme and a toggleTheme function in state. Add a button anywhere in the tree that calls toggleTheme, and a <body className={theme}> wrapper that reflects the current theme. Confirm every consumer updates without a single prop being passed down.

When Context is the wrong tool

Context is a poor fit for state that:

  • Changes often (every keystroke, every cursor move, scroll position).
  • Is read by only a few components close together — lift state to the nearest common parent instead.
  • Has complex update logic with derived values, selectors, and cross-slice dependencies.
  • Needs time-travel debugging, middleware, or persistence out of the box.

For those, reach for a state management library:

  • Zustand — tiny, hook-based, no provider needed. Great default for new apps.
  • Jotai — atomic state, fine-grained subscriptions.
  • Redux Toolkit — the established choice for large apps, devtools and middleware included.
  • TanStack Query — for server state (caching, refetching). Most “context for fetched data” code is better written with this.

The rule of thumb: if you find yourself reaching for useMemo and useCallback everywhere just to keep your context value stable, you have probably outgrown Context.

The performance gotcha

Here is the thing every React developer eventually trips over.

Every consumer of a context re-renders when the context value changes. Not when “their slice” changes — when the value reference changes at all.

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  // New object every render — every consumer re-renders every render
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

A button that only cares about theme will re-render whenever user changes, and vice versa. For a handful of consumers this is fine. For hundreds it is a problem.

Fix 1: split contexts

The simplest and most effective fix is to put unrelated state in separate contexts:

<ThemeProvider>
  <AuthProvider>
    <App />
  </AuthProvider>
</ThemeProvider>

Now a themed-button only subscribes to ThemeContext and is untouched by auth changes.

Fix 2: separate “value” and “actions”

If the setters are stable but the value changes, split them:

const CountValueContext = createContext(0);
const CountActionsContext = createContext(null);

function CountProvider({ children }) {
  const [count, setCount] = useState(0);

  // actions object never changes — memoise once
  const actions = useMemo(() => ({
    increment: () => setCount((c) => c + 1),
    reset: () => setCount(0),
  }), []);

  return (
    <CountActionsContext.Provider value={actions}>
      <CountValueContext.Provider value={count}>
        {children}
      </CountValueContext.Provider>
    </CountActionsContext.Provider>
  );
}

A component that only dispatches actions reads CountActionsContext and never re-renders when count changes. A component that displays the count reads CountValueContext.

Fix 3: memoise the value object

If you must put multiple values in one context, memoise the object so its reference is stable:

const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);

Without useMemo, value is a brand new object every render and every consumer re-renders.

Try it yourself. Build a provider that stores count and name in one context. Add two consumers: one reads count, one reads name. Add a console.log to each render. Update name and watch both consumers re-render. Now split the context into two, repeat — only the relevant consumer re-renders.

Patterns that age well

A few habits keep Context code maintainable as the app grows.

Export a hook, not the context. Consumers import useAuth(), not AuthContext. You keep the freedom to change the implementation later (split contexts, swap to Zustand) without rewriting every call site.

Throw if the provider is missing. The null-check pattern in the useAuth example above catches a whole class of “wrapped the wrong subtree” bugs at the moment they occur.

Keep providers thin. A provider that does too much (fetches data, runs effects, manages five pieces of state) becomes a god component. Split it.

Compose providers at the root. It is fine to wrap your app in five providers. It is not fine to have providers scattered randomly through the tree unless there is a real reason.

Context vs lifting state vs a store

A small decision guide:

SituationUse
Two siblings need the same valueLift state to the parent
A value is needed by many components across the tree, changes rarelyContext
State updates every keystroke and is read by many componentsA store (Zustand, Redux, Jotai)
Data comes from a server and needs cachingTanStack Query or SWR
One component needs server data onceA plain useEffect fetch is fine

You can — and should — combine these. A typical real app uses TanStack Query for server data, Context for auth and theme, and useState for everything local. Reach for a global store only when the simpler tools are clearly straining.

A worked example: locale switching

A small but realistic context that ties everything together:

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

const messages = {
  en: { hello: 'Hello', bye: 'Goodbye' },
  fr: { hello: 'Bonjour', bye: 'Au revoir' },
};

const LocaleContext = createContext(null);

export function LocaleProvider({ children }) {
  const [locale, setLocale] = useState('en');

  const value = useMemo(() => ({
    locale,
    setLocale,
    t: (key) => messages[locale][key] ?? key,
  }), [locale]);

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

export function useLocale() {
  const ctx = useContext(LocaleContext);
  if (!ctx) throw new Error('useLocale must be used inside <LocaleProvider>');
  return ctx;
}

Consumers do not care how t resolves the message — they just call t('hello'). Switching languages re-renders the whole tree exactly once.

Recap

You now know:

  • Context is a delivery mechanism, not a state manager. The state lives in a useState/useReducer inside the provider.
  • The three pieces are createContext, Provider, and useContext — usually wrapped behind a custom hook.
  • Context fits values that are broad-read, low-write: theme, auth, locale, routing.
  • For high-frequency updates, complex selectors, or server state, reach for Zustand, Redux, or TanStack Query instead.
  • The big performance pitfall is that every consumer re-renders when the value reference changes — split contexts or memoise the value to fix it.

Next steps

Once you are comfortable with Context, the next natural step is extracting logic into reusable hooks of your own.

→ Next: Building Custom React Hooks

Questions or feedback? Email codeloomdevv@gmail.com.