Home JavaScript React Context API Explained: Avoid Prop Drilling and Share State Globally

React Context API Explained: Avoid Prop Drilling and Share State Globally

In Plain English 🔥
Imagine your school has a PA system. Instead of a teacher whispering a message to one student, who whispers it to the next, all the way down the hall, the principal just speaks into the microphone and every classroom hears it at once. React Context is that PA system — it lets any component in your app 'hear' shared data directly, without it being passed down one room at a time.
⚡ Quick Answer
Imagine your school has a PA system. Instead of a teacher whispering a message to one student, who whispers it to the next, all the way down the hall, the principal just speaks into the microphone and every classroom hears it at once. React Context is that PA system — it lets any component in your app 'hear' shared data directly, without it being passed down one room at a time.

Every React app eventually hits the same wall. You have a piece of data — maybe the logged-in user's name, a theme preference, or a shopping cart — and suddenly you need it five components deep. So you start passing it as a prop, then passing it again, then again. Your middle components don't even use the data; they're just relay runners carrying a baton they'll never touch. This is prop drilling, and it's the silent killer of maintainable React codebases.

React Context API exists to solve exactly this. It lets you declare data at a high level in your component tree and make it available to any descendant — no matter how deep — without threading props through every layer in between. It's not a replacement for state management libraries like Redux or Zustand in every scenario, but for a huge category of real-world problems (auth state, themes, locale, feature flags), it's the right tool and it's built right into React.

By the end of this article you'll understand not just how to create and consume a Context, but — more importantly — when you should reach for it and when you shouldn't. You'll have a production-ready pattern for an authentication context, a clear mental model for performance implications, and the vocabulary to talk about it confidently in an interview.

The Problem Context Solves: Prop Drilling in the Real World

Before writing a single line of Context code, it's worth feeling the pain it eliminates. Prop drilling isn't just annoying — it creates real, compounding problems.

First, it creates tight coupling. Your Navbar component now has to accept a currentUser prop purely so it can hand it to UserAvatar. Navbar doesn't care about currentUser. It shouldn't know it exists. But now it does, forever.

Second, it makes refactoring brutal. Move a component to a different part of the tree? Now you need to rewire the entire prop chain.

Third, it's a maintenance trap. Six months later, a new developer sees currentUser threaded through four components and has no idea why. They're afraid to touch it.

Context breaks this chain entirely. You define the data once at a high-level provider, and any component that actually needs it can reach up and grab it directly — like plugging into a wall socket instead of running an extension cord through six rooms.

The key insight: Context is not about making things easier to write. It's about making your component interfaces honest. Components should only accept props they actually use.

PropDrillingProblem.jsx · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839
// ❌ THE PROBLEM: currentUser drills through components that don't need it
// App knows the user. UserAvatar needs it. But Navbar and Header are just middlemen.

function App() {
  const currentUser = { name: 'Maya', role: 'admin', avatarUrl: '/maya.png' };

  // App passes currentUser to Navbar even though Navbar doesn't use it directly
  return <Navbar currentUser={currentUser} />;
}

function Navbar({ currentUser }) {
  // Navbar doesn't display the user — it just passes the prop along. Pure relay.
  return (
    <nav>
      <Logo />
      <Header currentUser={currentUser} />
    </nav>
  );
}

function Header({ currentUser }) {
  // Header also doesn't use currentUser — still just passing it down
  return (
    <header>
      <h1>Dashboard</h1>
      <UserAvatar currentUser={currentUser} />
    </header>
  );
}

function UserAvatar({ currentUser }) {
  // Only THIS component actually needed currentUser all along
  return (
    <div className="avatar">
      <img src={currentUser.avatarUrl} alt={currentUser.name} />
      <span>{currentUser.name}</span>
    </div>
  );
}
▶ Output
// No runtime error — it works. But Navbar and Header now carry a prop they never use.
// Add another prop? Drill it again. This is how codebases become unmaintainable.
🔥
The Rule of Thumb:If a prop passes through two or more components without being used by those middle components, that's your signal to reach for Context. One or two levels of passing is fine — that's just normal composition.

Building a Real Auth Context: createContext, Provider, and useContext

Context has three moving parts: the context object itself, a Provider that wraps your component tree and supplies the value, and consumers that read that value. The modern way to consume context is the useContext hook — it's clean, readable, and works inside any function component.

Here's the pattern used in production apps. Rather than exporting the raw context and calling useContext everywhere, you create a custom hook — useAuth in this case. This gives you one place to add error handling, and it hides the Context implementation detail from consumers. Components don't need to know how auth works, just that they can call useAuth() and get what they need.

The Provider component is the scope boundary. Every component rendered inside can access the auth state. Components outside it cannot. This is powerful — you can have multiple providers with different scopes in the same app.

Notice the structure: AuthProvider owns the state with useState. It computes derived values (like isAdmin). It exposes functions like login and logout. This keeps all auth logic in one place — the context file — and keeps your individual components clean and dumb.

AuthContext.jsx · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
import React, { createContext, useContext, useState } from 'react';

// Step 1: Create the context with a meaningful default value of null.
// The null signals "there is no provider above this — something is wrong."
const AuthContext = createContext(null);

// Step 2: Build the Provider component. This is what wraps your app.
// It owns the state and decides what to expose.
export function AuthProvider({ children }) {
  const [currentUser, setCurrentUser] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  // Simulate an async login (replace with a real API call)
  async function login(email, password) {
    setIsLoading(true);
    // In a real app: const user = await authService.login(email, password);
    const mockUser = { id: '42', name: 'Maya Patel', email, role: 'admin' };
    setCurrentUser(mockUser);
    setIsLoading(false);
  }

  function logout() {
    setCurrentUser(null);
  }

  // Derived state: compute this once here, not in every component that needs it
  const isAdmin = currentUser?.role === 'admin';
  const isAuthenticated = currentUser !== null;

  // The value object is what every consumer will receive
  const contextValue = {
    currentUser,
    isAuthenticated,
    isAdmin,
    isLoading,
    login,
    logout,
  };

  return (
    <AuthContext.Provider value={contextValue}>
      {children}
    </AuthContext.Provider>
  );
}

// Step 3: Create a custom hook. This is the ONLY way components should access auth.
// The error guard here is gold — it catches misuse immediately during development.
export function useAuth() {
  const context = useContext(AuthContext);

  if (context === null) {
    // This throws if a component calls useAuth() outside of <AuthProvider>
    throw new Error(
      'useAuth must be used inside an <AuthProvider>. ' +
      'Make sure AuthProvider wraps this component in your tree.'
    );
  }

  return context;
}

// ─────────────────────────────────────────
// How to wire it up in your app root:
// ─────────────────────────────────────────

// index.jsx or main.jsx
// import { AuthProvider } from './AuthContext';
//
// ReactDOM.createRoot(document.getElementById('root')).render(
//   <AuthProvider>
//     <App />
//   </AuthProvider>
// );

// ─────────────────────────────────────────
// Using it in a deeply nested component:
// ─────────────────────────────────────────

function UserAvatar() {
  // One clean line. No props needed. No drilling.
  const { currentUser, logout, isAdmin } = useAuth();

  if (!currentUser) return <span>Guest</span>;

  return (
    <div className="user-avatar">
      <img src={`/avatars/${currentUser.id}.png`} alt={currentUser.name} />
      <span>{currentUser.name}</span>
      {isAdmin && <span className="badge">Admin</span>}
      <button onClick={logout}>Sign Out</button>
    </div>
  );
}
▶ Output
// When currentUser is null: renders <span>Guest</span>
// After login({ name: 'Maya', role: 'admin', ... }):
// renders avatar image, 'Maya Patel', an 'Admin' badge, and a 'Sign Out' button
//
// If you call useAuth() outside <AuthProvider>:
// Error: useAuth must be used inside an <AuthProvider>...
⚠️
Pro Tip: Always Wrap in a Custom HookNever export the raw context and call `useContext(AuthContext)` directly in your components. The custom hook pattern gives you one place to add the null-check guard, one place to add logging, and the freedom to change the underlying implementation later without touching every consumer.

Context Performance: Why Re-renders Happen and How to Control Them

Here's the part most tutorials skip — and the part that will bite you in a real app. Every time the value prop on a Provider changes, every component that consumes that context will re-render. That sounds obvious, but the trap is subtle: if you create a new object inline as the value, it's a new object reference on every render, even if the actual data didn't change.

This becomes a real problem when your Provider's parent re-renders for unrelated reasons. Suddenly, every consumer across your entire app re-renders too, for nothing.

The fix is useMemo for the value object and useCallback for the functions inside it. This stabilises the references — React's reconciler can compare them and say 'same reference, no re-render needed'.

The deeper fix, though, is context splitting. Instead of one giant context with everything in it, split by update frequency. Auth data (rarely changes) in one context. UI state like theme (changes on button click) in another. Shopping cart (changes frequently) in a third. Now a theme toggle only re-renders theme consumers, not your entire app.

This is how production apps stay fast as they scale.

OptimisedThemeContext.jsx · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react';

// Split contexts: one for the value, one for the setter
// This is the advanced pattern — components that only READ the theme
// won't re-render when the SETTER function reference changes
const ThemeValueContext = createContext(null);
const ThemeActionsContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light'); // 'light' | 'dark' | 'system'

  // useCallback stabilises the reference to toggleTheme across re-renders
  // Without this, toggleTheme is a brand-new function every render
  const toggleTheme = useCallback(() => {
    setTheme(previous => previous === 'light' ? 'dark' : 'light');
  }, []); // Empty deps: this function never needs to be recreated

  const setSystemTheme = useCallback(() => {
    setTheme('system');
  }, []);

  // useMemo stabilises the value object so its reference only changes
  // when `theme` actually changes — not on every parent re-render
  const themeValue = useMemo(() => ({
    theme,
    isDark: theme === 'dark',
    isLight: theme === 'light',
  }), [theme]); // Only recompute when theme changes

  // Actions are stable (useCallback above), so this object is also stable
  const themeActions = useMemo(() => ({
    toggleTheme,
    setSystemTheme,
  }), [toggleTheme, setSystemTheme]);

  return (
    <ThemeValueContext.Provider value={themeValue}>
      <ThemeActionsContext.Provider value={themeActions}>
        {children}
      </ThemeActionsContext.Provider>
    </ThemeValueContext.Provider>
  );
}

// Separate hooks for reading vs acting — pure reads won't re-render on action changes
export function useTheme() {
  const context = useContext(ThemeValueContext);
  if (!context) throw new Error('useTheme must be used inside <ThemeProvider>');
  return context;
}

export function useThemeActions() {
  const context = useContext(ThemeActionsContext);
  if (!context) throw new Error('useThemeActions must be used inside <ThemeProvider>');
  return context;
}

// ─────────────────────────────────────────
// Usage in components:
// ─────────────────────────────────────────

function ThemedBackground({ children }) {
  // This component re-renders only when theme value changes
  const { theme, isDark } = useTheme();

  return (
    <div
      className={`app-background ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'}`}
      data-theme={theme}
    >
      {children}
    </div>
  );
}

function ThemeToggleButton() {
  // This component ONLY needs the action — it never re-renders due to theme value changes
  const { toggleTheme } = useThemeActions();
  // Tip: wrap in React.memo to prevent re-renders from parent components too
  return <button onClick={toggleTheme}>Toggle Theme</button>;
}
▶ Output
// Initial render: theme = 'light', isDark = false
// ThemedBackground renders with class 'app-background bg-white text-gray-900'
//
// After toggleTheme() click:
// theme = 'dark', isDark = true
// ThemedBackground re-renders with class 'app-background bg-gray-900 text-white'
// ThemeToggleButton does NOT re-render (it only consumes ThemeActionsContext,
// which didn't change because toggleTheme reference is stable via useCallback)
⚠️
Watch Out: The Inline Object TrapWriting `` directly in JSX creates a new object on every render — even if user and setUser didn't change. Every consumer re-renders every time. Always extract the value into a useMemo'd variable before passing it to the Provider.

Context vs. Prop Drilling vs. State Libraries: When to Use What

Context is powerful, but it's not always the right answer. Picking the wrong tool leads to messy code or sluggish performance. Here's how to think about it.

Prop drilling is fine and actually preferable for local, closely-related components. If ProductCard needs to pass price to PriceTag, that's one level. Do it with props. Don't over-engineer it.

Context is the sweet spot for app-wide or feature-wide state that doesn't change super frequently: authentication, user preferences, theme, locale, feature flags. It's also great for React library authors building component systems (like a that needs to share open/close state with its children without leaking that state to the outside world).

External state managers like Zustand, Redux Toolkit, or Jotai are the right call when you have complex state logic (reducers, middleware, time-travel debugging), high-frequency updates (real-time data, animations), or state that needs to be persisted, synced across tabs, or hydrated from a server.

The most common mistake is jumping to Redux the moment prop drilling gets annoying. Context handles a huge middle ground. Use it first, reach for a library when Context genuinely isn't enough.

AccordionContext.jsx · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Real-world compound component pattern: Context scoped to a feature, not the whole app.
// The Accordion shares open/close state with AccordionItem internally.
// Neither the parent nor the developer using this component needs to know it uses Context.

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

// This context is PRIVATE to the Accordion feature — not exported
const AccordionContext = createContext(null);

function Accordion({ children, allowMultiple = false }) {
  // openItems holds the set of currently-expanded item IDs
  const [openItems, setOpenItems] = useState(new Set());

  function toggleItem(itemId) {
    setOpenItems(previous => {
      const updated = new Set(previous);

      if (updated.has(itemId)) {
        updated.delete(itemId); // Collapse this item
      } else {
        if (!allowMultiple) updated.clear(); // Close all others first
        updated.add(itemId); // Expand this item
      }

      return updated;
    });
  }

  return (
    <AccordionContext.Provider value={{ openItems, toggleItem }}>
      <div className="accordion" role="presentation">
        {children}
      </div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ itemId, title, children }) {
  // AccordionItem reaches into Accordion's context directly
  // The developer using <AccordionItem> doesn't pass any open/close props
  const { openItems, toggleItem } = useContext(AccordionContext);
  const isOpen = openItems.has(itemId);

  return (
    <div className="accordion-item">
      <button
        className="accordion-trigger"
        aria-expanded={isOpen}
        onClick={() => toggleItem(itemId)}
      >
        {title}
        <span aria-hidden>{isOpen ? '▲' : '▼'}</span>
      </button>

      {/* Only render children in the DOM when the panel is open */}
      {isOpen && (
        <div className="accordion-panel" role="region">
          {children}
        </div>
      )}
    </div>
  );
}

// The developer using this never sees the Context — clean public API
function FrequentlyAskedQuestions() {
  return (
    <Accordion allowMultiple={false}>
      <AccordionItem itemId="shipping" title="How long does shipping take?">
        <p>Standard shipping takes 35 business days.</p>
      </AccordionItem>
      <AccordionItem itemId="returns" title="What is the return policy?">
        <p>You can return any item within 30 days of purchase.</p>
      </AccordionItem>
      <AccordionItem itemId="payment" title="Which payment methods are accepted?">
        <p>We accept Visa, Mastercard, and PayPal.</p>
      </AccordionItem>
    </Accordion>
  );
}
▶ Output
// Renders three accordion rows. Clicking 'How long does shipping take?' expands it.
// Clicking 'What is the return policy?' closes the first and expands the second
// (because allowMultiple={false}).
// The parent component <FrequentlyAskedQuestions> manages zero state — all handled internally.
🔥
Interview Gold: The Compound Component PatternThis Accordion example is the 'compound component pattern' — Context scoped privately to a feature so sibling components can share state without exposing it to their parent. It's a favourite interview topic at companies building component libraries. Know it cold.
AspectReact Context APIExternal Library (e.g. Zustand)
Setup complexityZero — built into React, no installMinimal but requires npm install + store setup
Best forAuth, theme, locale, low-frequency global stateComplex logic, high-frequency updates, middleware
Re-render controlManual — requires useMemo / context splittingBuilt-in selectors prevent unnecessary re-renders
DevTools supportReact DevTools shows Provider/Consumer treeDedicated devtools with time-travel in Redux
BoilerplateLow — custom hook + provider is ~30 linesLow (Zustand) to High (Redux with reducers/actions)
Learning curveLow — just React patterns you already knowLow (Zustand/Jotai) to High (Redux)
Works outside React componentsNo — hooks only work in componentsYes — Zustand/Redux store is accessible anywhere
Performance at scaleNeeds manual optimisation with useMemo/splittingOptimised by default via subscription model

🎯 Key Takeaways

  • Context doesn't eliminate state — it eliminates the manual passing of state through components that don't need it. The state still lives in one place; you're just changing the delivery mechanism.
  • Always wrap Context in a custom hook with a null-check guard. useAuth() is a far better API than useContext(AuthContext) scattered across your codebase — it's safer, more readable, and easier to refactor.
  • The inline object trap (value={{ user, logout }} directly in JSX) is the #1 performance mistake with Context. Stabilise your value with useMemo and your functions with useCallback so consumers only re-render when data actually changes.
  • Context is a mid-tier tool — reach for it when prop drilling gets painful, but don't reach for it for everything. High-frequency updates (real-time data, animations) still belong in an external store. Don't use a PA system to whisper to the person next to you.

⚠ Common Mistakes to Avoid

  • Mistake 1: Putting everything in one giant context — Symptom: toggling the theme causes your entire app to re-render, including unrelated components like the shopping cart — Fix: split your context by domain and update frequency. Auth in one context, theme in another, cart in a third. Components only re-render when the slice they subscribe to changes.
  • Mistake 2: Creating the context value inline in JSX — Symptom: causes every consumer to re-render on every parent render, even if user didn't change — Fix: extract the value into a useMemo'd variable inside the provider: const value = useMemo(() => ({ user, setUser }), [user]) and pass value to the Provider instead.
  • Mistake 3: Calling useContext (or a custom hook) outside the Provider — Symptom: context returns undefined or null and components silently render incorrectly, or you get a confusing 'cannot read property of undefined' error — Fix: always add a null-check guard inside your custom hook and throw a descriptive error: if (!context) throw new Error('useAuth must be inside AuthProvider'). This fails loudly in development so the bug is immediately obvious.

Interview Questions on This Topic

  • QWhat is the difference between React Context and prop drilling, and how do you decide which to use?
  • QIf a Context value changes, which components re-render? How would you prevent unnecessary re-renders in a large app using Context?
  • QCan you explain the compound component pattern and how Context enables it? Walk me through building a reusable Tabs component using this pattern.

Frequently Asked Questions

Is React Context a replacement for Redux?

For many apps, yes — Context handles auth, theme, and locale state without any extra dependencies. But Redux (or Zustand) remains the better choice when you have complex state transitions, need middleware, want time-travel debugging, or have high-frequency updates that require subscription-based re-render control. Use Context first; reach for a library when it's genuinely not enough.

Does React Context cause performance problems?

It can, if you're not careful. The core issue is that every consumer re-renders when the context value changes — and the value changes whenever its object reference changes. Wrapping your value in useMemo and functions in useCallback prevents unnecessary reference changes. Splitting one large context into smaller domain-specific contexts also limits re-renders to only the components that care about a specific slice of state.

Can I have multiple Context providers in the same React app?

Absolutely, and it's the recommended approach. Most production apps have several: one for auth, one for theme, one for cart, and so on. You simply nest them in your root — . Each provider is independent, and components subscribe only to the contexts they actually need.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousReact Forms and Controlled ComponentsNext →React Performance Optimisation
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged