React

useContext — Sharing State Without Prop Drilling

Thirdy Gayares
12 min read

🎓 What You Will Learn

  • The prop drilling problem: why passing props through 4+ levels hurts
  • createContext + Provider: making a value available to a whole subtree
  • useContext: reading that value from any descendant, at any depth
  • Context + useState: sharing state that other components can update
  • Custom hook wrapper: the useAuth() / useCart() pattern
  • When NOT to use context: the cases where prop drilling — or a state library — wins
1

The Prop Drilling Problem

Props flow one way: parent to child. That's fine until a deeply nested component needs a value that only the top-level component has. You end up threading that prop through every component in between — components that don't use it, they just pass it along.

prop-drilling.jsx
function App() {
  const [theme, setTheme] = useState("light");
  return <Page theme={theme} />;
}

function Page({ theme }) {
  return <Sidebar theme={theme} />; // Page never uses theme itself
}

function Sidebar({ theme }) {
  return <UserCard theme={theme} />; // neither does Sidebar
}

function UserCard({ theme }) {
  return <div className={theme}>...</div>; // finally, someone uses it
}

This is prop drilling. It's not wrong, but it makes every middle component depend on a prop it doesn't care about, and renaming or restructuring becomes painful. Context solves exactly this.

2

createContext + Provider + useContext

Three pieces: createContext() makes a context object, <Context.Provider value={...}> makes a value available to every component inside it, and useContext(Context) reads that value from anywhere in that subtree — no matter how many components sit in between.

This card reads theme straight from context — no props were passed to it.

Notice: ThemedCardWrapper renders <ThemedCard /> without ever mentioning theme. That's the entire point — middle components stay clean.
3

Context + useState — Shared, Updatable State

A static value is nice, but most real contexts pair with useState so descendants can both read and update shared state — the value you provide is just an object holding the state and the functions that change it.

The custom hook wrapper (useAuth()) does two jobs: it hides useContext(AuthContext) behind a friendlier name, and it throws a clear error if someone forgets to wrap their tree in the Provider — instead of a confusing null crash three components later.
4

Sharing State Across Unrelated Branches

Context really earns its keep when two components that aren't parent/child — like a header badge and a product card in a completely different part of the tree — need the same live state.

Header (far from ProductCard)
🛒 0 in cart
🎧 Headphones — $79
5

Composing Multiple Contexts

Real apps usually have more than one context (theme, auth, cart...). Nest their Providers, or write one combined AppProviders component to keep your root tidy.

AppProviders.jsx
function AppProviders({ children }) {
  return (
    <ThemeContext.Provider value={theme}>
      <AuthContext.Provider value={authValue}>
        <CartContext.Provider value={cartValue}>
          {children}
        </CartContext.Provider>
      </AuthContext.Provider>
    </ThemeContext.Provider>
  );
}

function App() {
  return (
    <AppProviders>
      <Page />
    </AppProviders>
  );
}
6

When NOT to Reach for Context

Context is not a performance-free global store. Every component that calls useContext re-renders whenever the Provider's value changes — even if the component only cares about one field of a large object.

Skip context (or split it into smaller contexts) when:

  • The value changes very frequently (e.g. mouse position, a text input on every keystroke) and many components consume it
  • You only need to pass a prop 1–2 levels — that's not drilling, that's just props
  • You need selective re-renders, time-travel debugging, or middleware — reach for Zustand or Redux Toolkit instead
7

Common Mistakes

1Forgetting the Provider
mistake-no-provider.jsx
// ❌ BAD: no <AuthContext.Provider> anywhere — useAuth() throws or returns the default
function App() {
  return <ProfileMenu />;
}

// ✅ GOOD: wrap the tree that needs the value
function App() {
  return (
    <AuthContext.Provider value={authValue}>
      <ProfileMenu />
    </AuthContext.Provider>
  );
}
2One giant context for everything
mistake-giant-context.jsx
// ❌ BAD: one context for theme + auth + cart —
// changing the cart count re-renders every theme consumer too
const AppContext = createContext({ theme, user, cart });

// ✅ GOOD: separate contexts, so a cart update
// doesn't re-render components that only read theme
const ThemeContext = createContext(theme);
const AuthContext = createContext(user);
const CartContext = createContext(cart);
3Recreating the value object every render
mistake-new-object.jsx
// ❌ BAD: a brand-new object every render — every consumer re-renders
<AuthContext.Provider value={{ user, login, logout }}>

// ✅ GOOD: memoize it so the reference is stable across renders
const value = useMemo(() => ({ user, login, logout }), [user]);
<AuthContext.Provider value={value}>
8

Best Practices

Wrap useContext in a custom hook (useAuth, useCart) with a helpful error
Split contexts by concern — don't put everything in one giant context
Memoize the value you pass to Provider to avoid needless re-renders
Reach for props first — context is for cross-cutting state, not every value
Reach for Zustand/Redux once you need selective re-renders or middleware
9

Pattern Reference

PiecePurpose
createContext(default)Creates the context object, with an optional fallback value
<Context.Provider value={...}>Makes a value available to every descendant inside it
useContext(Context)Reads the current value from the nearest Provider above
Custom hook wrapperHides useContext + throws if used outside a Provider
useMemo(() => value, [deps])Keeps the Provider's value reference stable
10

Practice Project

Build a small language switcher that:

  • Creates a LanguageContext holding the current language + a setter
  • Wraps a useLanguage() custom hook around it with a "must be inside Provider" error
  • Has a language picker in the header and a greeting deep in the page — both consuming the same context
  • Switching the language updates the greeting instantly, with zero props passed between them
11

What's Next?

🚀 Next Learning Topics:
  • useReducer: pair with context for more complex, action-based state
  • Custom Hooks: extract more reusable logic beyond useAuth/useCart
  • State Management: when to graduate from context to Zustand or Redux Toolkit
  • useMemo / React.memo: control re-renders once context consumers grow

Keep practicing! 💪 Context is one of the biggest "aha" moments in React — once prop drilling stops being your default answer, whole trees of components get simpler to write and refactor.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.