React Interview Prep
Hooks

useContext

Avoiding Prop Drilling

LinkedIn Hook

You pass theme through 6 components. None of them use it except the last one.

That is prop drilling. And it is one of the most common architectural complaints in React codebases.

useContext is React's built-in solution — but most developers either overuse it or misunderstand its performance implications. In interviews, "just use context" is not a good enough answer. They want to know when context is right, when it is wrong, and why it re-renders everything when your value changes.

In this lesson, I break down createContext, the Provider/Consumer pattern, the useContext hook, the cases where context shines (theme, auth, locale), and the performance gotcha that trips up even senior engineers.

If you have ever wrapped your entire app in 7 nested Providers and wondered if there was a better way — this one is for you.

Read the full lesson -> [link]

#React #JavaScript #InterviewPrep #Frontend #CodingInterview #ReactHooks #useContext #100DaysOfCode


useContext thumbnail


What You'll Learn

  • What prop drilling is and why it becomes a maintenance problem
  • How createContext, Provider, and useContext work together
  • When context is the right tool (theme, auth, locale, config)
  • When context is the wrong tool (frequent updates, fine-grained state)
  • The performance gotcha — why context re-renders every consumer and how to mitigate it

The Concept — What Is Prop Drilling?

Analogy: The Office Memo

Imagine a CEO writes a memo about the company dress code. In a badly organized office, the memo goes from the CEO to the VP, from the VP to the Director, from the Director to the Manager, from the Manager to the Team Lead, and finally to the Developer who actually needs it. Every person in that chain has to physically carry the memo, even though none of them read it. That is prop drilling — passing data through components that do not use it, just so it can reach the component that does.

Context is like installing a company-wide intercom system. The CEO speaks into the intercom, and anyone in the building who cares can listen directly. No one has to carry the memo. No middlemen.

But here is the catch — the intercom is loud. When the CEO changes the message, everyone who is listening hears the update, whether they needed that specific part of the message or not. That is the performance gotcha of context.


The Prop Drilling Problem

// WITHOUT context: theme must travel through every component in the chain
function App() {
  const [theme, setTheme] = useState("dark");
  return <Layout theme={theme} setTheme={setTheme} />;
}

function Layout({ theme, setTheme }) {
  // Layout does not use theme — just passes it down
  return <Sidebar theme={theme} setTheme={setTheme} />;
}

function Sidebar({ theme, setTheme }) {
  // Sidebar does not use theme either — just passes it down
  return <ThemeToggle theme={theme} setTheme={setTheme} />;
}

function ThemeToggle({ theme, setTheme }) {
  // Only THIS component actually needs theme
  return (
    <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
      Current: {theme}
    </button>
  );
}

// The problem: Layout and Sidebar carry theme and setTheme for no reason.
// If you rename the prop or add a new one, you must update every component in the chain.

Three components act as couriers for data they never use. This gets worse as your tree grows deeper.


createContext, Provider, and useContext

Context has three parts:

  1. createContext — creates a context object with a default value
  2. Provider — wraps a subtree and supplies the context value
  3. useContext — reads the nearest Provider's value from any descendant

Code Example 1: Basic Theme Context

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

// Step 1: Create the context with a default value
// The default is used only when a component reads context without a Provider above it
const ThemeContext = createContext("light");

// Step 2: Create a Provider component that holds the state
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("dark");

  return (
    // The value prop is what consumers will receive
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Step 3: Consume context with useContext — no props needed
function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
      Current theme: {theme}
    </button>
  );
}

// Now Layout and Sidebar do not need to know about theme at all
function App() {
  return (
    <ThemeProvider>
      <Layout />
    </ThemeProvider>
  );
}

function Layout() {
  return <Sidebar />;  // No theme prop — clean
}

function Sidebar() {
  return <ThemeToggle />;  // No theme prop — clean
}

// Output on first render: Button shows "Current theme: dark"
// After click: Button shows "Current theme: light"

useContext visual 1


Code Example 2: Auth Context (Real-World Pattern)

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

// Create a context for authentication
const AuthContext = createContext(null);

// Custom hook — wraps useContext and adds a safety check
function useAuth() {
  const context = useContext(AuthContext);
  if (context === null) {
    // Throw a clear error if someone uses useAuth outside the Provider
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
}

// Provider that manages all auth state in one place
function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

  function login(username) {
    // In a real app, this would call an API
    setUser({ name: username, role: "admin" });
  }

  function logout() {
    setUser(null);
  }

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

// Any component in the tree can access auth without prop drilling
function Navbar() {
  const { user, logout } = useAuth();

  return (
    <nav>
      {user ? (
        <span>
          Hello, {user.name} <button onClick={logout}>Logout</button>
        </span>
      ) : (
        <span>Not logged in</span>
      )}
    </nav>
  );
}

function LoginPage() {
  const { login } = useAuth();

  return <button onClick={() => login("Alice")}>Login as Alice</button>;
}

function App() {
  return (
    <AuthProvider>
      <Navbar />
      <LoginPage />
    </AuthProvider>
  );
}

// Initial render:
//   Navbar shows: "Not logged in"
//
// After clicking "Login as Alice":
//   Navbar shows: "Hello, Alice [Logout]"
//
// After clicking "Logout":
//   Navbar shows: "Not logged in"

Key pattern: Wrapping useContext in a custom hook (useAuth) is a best practice. It provides a clean API, adds error handling when used outside a Provider, and keeps consumers from importing the raw context object.

useContext visual 2


When Context Is Right

Context works best for low-frequency, widely-needed values:

Use CaseWhy It Fits
Theme (dark/light mode)Changes rarely, needed by many components
Auth (current user)Changes on login/logout only, needed everywhere
Locale / i18n (language)Changes rarely, affects text across the app
Feature flagsSet once, read by many components
Router infoReact Router uses context internally

The common thread: these values change infrequently and are needed by components far apart in the tree.


When Context Is Wrong

Code Example 3: Context Is Wrong for Frequent Updates

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

// BAD: Using context for rapidly changing state
const MouseContext = createContext({ x: 0, y: 0 });

function MouseProvider({ children }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  // Mouse moves fire dozens of times per second
  function handleMouseMove(e) {
    setPosition({ x: e.clientX, y: e.clientY });
  }

  return (
    <div onMouseMove={handleMouseMove}>
      <MouseContext.Provider value={position}>
        {children}
      </MouseContext.Provider>
    </div>
  );
}

// PROBLEM: Every component that reads MouseContext re-renders
// on every mouse move — even if it only needs x, not y
function XDisplay() {
  const { x } = useContext(MouseContext); // Re-renders when y changes too
  return <p>X: {x}</p>;
}

function YDisplay() {
  const { y } = useContext(MouseContext); // Re-renders when x changes too
  return <p>Y: {y}</p>;
}

// Both XDisplay and YDisplay re-render on every mouse move.
// If 20 components consume MouseContext, all 20 re-render on every move.
// This creates visible jank at 60+ updates per second.

// BETTER APPROACH: Use a state management library (Zustand, Jotai)
// that supports selecting individual values without re-rendering
// on unrelated changes. Or lift the mouse tracking into the
// component that actually needs it.

Rule of thumb: If a value changes more than a few times per second, context is the wrong tool. Use a state management library with selectors, or keep the state local to the component that needs it.


The Performance Gotcha

This is what interviewers really want to hear. Here is the problem and the mitigation:

The problem: When a Provider's value changes, every component that calls useContext for that context re-renders. There is no built-in selector. You cannot subscribe to just one field of the context value.

Code Example 4: The Gotcha and the Fix

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

// PROBLEM: Creating a new object on every render
function BadProvider({ children }) {
  const [user, setUser] = useState("Alice");
  const [theme, setTheme] = useState("dark");

  return (
    // { user, theme, setUser, setTheme } is a NEW object every render
    // Even if nothing changed, all consumers re-render
    <AppContext.Provider value={{ user, theme, setUser, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

// FIX 1: Memoize the context value
function BetterProvider({ children }) {
  const [user, setUser] = useState("Alice");
  const [theme, setTheme] = useState("dark");

  // useMemo ensures a new object is only created when user or theme changes
  const value = useMemo(
    () => ({ user, theme, setUser, setTheme }),
    [user, theme]
  );

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

// FIX 2: Split contexts by update frequency
const UserContext = createContext(null);
const ThemeContext = createContext(null);

function SplitProvider({ children }) {
  const [user, setUser] = useState("Alice");
  const [theme, setTheme] = useState("dark");

  return (
    // Components that only need theme will not re-render when user changes
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        {children}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

// Now a component can subscribe to just what it needs
function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);
  // Only re-renders when theme changes — user changes are ignored
  return (
    <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
      {theme}
    </button>
  );
}

function UserGreeting() {
  const { user } = useContext(UserContext);
  // Only re-renders when user changes — theme changes are ignored
  return <p>Hello, {user}</p>;
}

// Output: ThemeToggle and UserGreeting update independently
// Changing theme does not re-render UserGreeting
// Changing user does not re-render ThemeToggle

Two mitigation strategies:

  1. Memoize the value with useMemo so the object reference stays stable when contents have not changed
  2. Split contexts by update frequency — separate things that change at different rates into different contexts

useContext visual 3


Common Mistakes

Mistake 1: Forgetting to wrap with a Provider

const ThemeContext = createContext("light");

function App() {
  // No ThemeProvider wrapping the tree
  return <ThemeToggle />;
}

function ThemeToggle() {
  const theme = useContext(ThemeContext);
  // theme will be "light" — the default value from createContext
  // No error is thrown. It just silently uses the default.
  // This is extremely hard to debug.

  return <p>{theme}</p>;
}

// FIX: Always wrap with a custom hook that throws when Provider is missing
function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

Use undefined (not a real value) as the default for createContext, then check for it in your custom hook. This catches the "missing Provider" bug immediately instead of failing silently.

Mistake 2: Putting everything in one giant context

// BAD: One context for the entire app state
const AppContext = createContext(null);

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("dark");
  const [cart, setCart] = useState([]);
  const [notifications, setNotifications] = useState([]);

  // Every consumer re-renders when ANY of these values change
  // Adding an item to cart re-renders the ThemeToggle
  return (
    <AppContext.Provider value={{ user, theme, cart, notifications, setUser, setTheme, setCart, setNotifications }}>
      {children}
    </AppContext.Provider>
  );
}

// BETTER: Separate contexts by domain
// AuthContext for user
// ThemeContext for theme
// CartContext for cart
// NotificationContext for notifications

Each context should hold one concern. If unrelated state is bundled together, every consumer pays the re-render cost of every change.

Mistake 3: Creating a new object reference on every render

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

  return (
    // WRONG: { count, setCount } is a new object every render
    // All consumers re-render even if count did not change
    <CountContext.Provider value={{ count, setCount }}>
      {children}
    </CountContext.Provider>
  );
}

// FIX: Memoize the value
function Provider({ children }) {
  const [count, setCount] = useState(0);
  const value = useMemo(() => ({ count, setCount }), [count]);

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

This is subtle because the component works correctly in both cases — but the performance difference is significant in large trees.


Interview Questions

Q: What is prop drilling and how does useContext solve it?

Prop drilling is passing data through multiple intermediate components that do not use the data — they just forward it to the next component. useContext solves this by letting any descendant component read a value directly from a Provider ancestor without the intermediate components needing to know about it. You create a context with createContext, wrap a subtree with a Provider that supplies the value, and call useContext in any descendant to read it.

Q: When should you use context and when should you avoid it?

Use context for values that are needed by many components across the tree and change infrequently — theme, authentication, locale, feature flags. Avoid context for frequently changing state (mouse position, real-time data, typing input) because every consumer re-renders on every change with no way to subscribe selectively. For frequent updates, use a state management library with selectors (Zustand, Jotai) or keep the state local.

Q: What happens when a context value changes? How does it affect consumers?

When a Provider's value prop changes (by reference, using Object.is comparison), React re-renders every component that calls useContext for that context. There is no built-in way to skip the re-render or select a subset of the value. This means if the value is an object with 5 fields and only 1 field changes, all consumers re-render — even those that only read the 4 unchanged fields.

Q: How do you optimize context performance?

Two main strategies: (1) Memoize the context value with useMemo so the object reference stays stable when the actual data has not changed. (2) Split unrelated state into separate contexts so consumers only subscribe to what they need. A component reading ThemeContext should not re-render because UserContext changed. Additionally, wrapping consumer components in React.memo does not help — context changes bypass memo.

Q: Why does wrapping a consumer in React.memo not prevent re-renders from context changes?

React.memo only prevents re-renders caused by new props from a parent. Context changes trigger re-renders through a different mechanism — React directly notifies all consumers of a context when the Provider value changes, bypassing the memo check entirely. The only ways to prevent unnecessary context re-renders are to split the context or to restructure the component so the useContext call happens in a small wrapper component that passes primitive props to a memoized child.


Quick Reference — Cheat Sheet

+-----------------------------------+-------------------------------------------+
| Concept                           | Key Point                                 |
+-----------------------------------+-------------------------------------------+
| createContext(default)            | Creates a context. Default is used only   |
|                                   | when no Provider is found above.          |
+-----------------------------------+-------------------------------------------+
| <Ctx.Provider value={...}>        | Supplies the context value to all          |
|                                   | descendants. Must wrap the subtree.       |
+-----------------------------------+-------------------------------------------+
| useContext(Ctx)                    | Reads the nearest Provider's value.       |
|                                   | Re-renders when that value changes.       |
+-----------------------------------+-------------------------------------------+
| Custom hook pattern               | Wrap useContext in a custom hook.          |
| useAuth = useContext + check      | Add error if Provider is missing.         |
+-----------------------------------+-------------------------------------------+
| Good use cases                    | Theme, auth, locale, feature flags,       |
|                                   | config — infrequent changes, wide need.   |
+-----------------------------------+-------------------------------------------+
| Bad use cases                     | Mouse position, real-time data, text      |
|                                   | input — frequent changes cause jank.      |
+-----------------------------------+-------------------------------------------+
| Performance gotcha                | All consumers re-render on any change.    |
|                                   | React.memo does NOT help.                 |
+-----------------------------------+-------------------------------------------+
| Fix: useMemo the value            | Stabilize object reference so unchanged   |
|                                   | renders do not trigger consumer updates.  |
+-----------------------------------+-------------------------------------------+
| Fix: split contexts               | Separate concerns into different contexts |
|                                   | so unrelated changes do not cross-fire.   |
+-----------------------------------+-------------------------------------------+

RULE: Context is for low-frequency, widely-needed state.
RULE: Always wrap useContext in a custom hook with a missing-Provider check.
RULE: Never put unrelated state in the same context.

Previous: Lesson 3.4 — useMemo & useCallback -> Next: Lesson 3.6 — useReducer — Complex State Logic ->


This is Lesson 3.5 of the React Interview Prep Course — 10 chapters, 42 lessons.

On this page