Context API & Props Drilling

The Problem: Prop Drilling

Previously, we learnt that data flows down via Props. But what if we have a user object in our top-level <App /> and we need it in a <Avatar /> component deeply nested inside the Navbar?

The Drilling approach: AppLayoutHeaderUserMenuAvatar

Why is this bad?

  1. Clutter: Layout and Header don't need user data, but they must accept it as props just to pass it down.
  2. Maintenance: If we rename the prop, we have to update 5 files.

The Solution: The Context API

Context provides a way to pass data through the component tree without having to pass props down manually at every level. Think of it as Data Teleportation.

The 3-Step Process

Step 1: Create the Context

We create a store for our data outside of any component.

// AppProvider.tsx
import { createContext } from "react";
 
// Define the shape of our context
interface AppContextValue {
  user: { name: string };
  theme: "light" | "dark";
  toggleTheme: () => void;
}
 
// Create the context with null as default
const AppContext = createContext<AppContextValue | null>(null);

Step 2: The Custom Provider Pattern

In production, we wrap the raw provider in a custom component to manage the state logic.

// AppProvider.tsx (Continued)
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
  const [user, setUser] = useState({ name: "Alice" });
  const [theme, setTheme] = useState<"light" | "dark">("light");
 
  const toggleTheme = () => {
    setTheme((prev) => (prev === "light" ? "dark" : "light"));
  };
 
  // ⚠️ CRITICAL: We are bundling unrelated state (User + Theme)
  const contextValue = { user, theme, toggleTheme };
 
  return (
    <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
  );
};

Step 3: Consume the Data (The Hook)

We create a custom hook to access the context easily and safely.

// AppProvider.tsx (Continued)
import { useContext } from "react";
 
export const useApp = () => {
  const context = useContext(AppContext);
  if (!context) throw new Error("useApp must be used within AppProvider");
  return context;
};

The Limitation: Unnecessary Re-renders

This is the biggest pitfall of Context. If we bundle unrelated state (like User and Theme) into a single Context, we force all consumers to re-render when any part of that data changes.

The Scenario: Two Separate Components

We have two components that consume our useApp hook.

  1. UserInfo (Only cares about User)
// components/user-info.tsx
const UserInfo = () => {
  const { user } = useApp();
 
  // This log helps us track renders
  console.log("UserInfo is rendered.");
 
  return <div>Welcome, {user.name}</div>;
};
  1. ToggleTheme (Only cares about Theme)
// components/toggle-theme.tsx
const ToggleTheme = () => {
  const { theme, toggleTheme } = useApp();
 
  console.log("ToggleTheme is rendered.");
 
  return <Button onClick={toggleTheme}>Current mode: {theme}</Button>;
};

The Click Experiment

What happens when we click the button in <ToggleTheme />?

  1. Action: We click Current mode.
  2. State Update: toggleTheme runs in AppProvider. theme toggles.
  3. Provider Re-runs: The AppProvider component re-renders.
  4. Object Recreation: The contextValue object is recreated:
    // A BRAND NEW object is created in memory
    const contextValue = { user, theme, toggleTheme };
  5. Notification: React sees the value prop changed (Old Object !== New Object). It notifies All consumers.

The Result in Console:

> ToggleTheme is rendered.
> UserInfo is rendered.   <-- WHY? The user didn't change!

Explanation: Even though UserInfo only destructures { user }, it subscribes to the entire AppContext. Because the AppProvider created a new object for value, UserInfo thinks the data changed and re-renders to stay safe.

In large apps, this causes major performance issues.

📝 Summary Table

ConceptDefinitionKey Role
Prop DrillingPassing props through layers that don't need them.The problem Context solves.
createContextCreates a context object.The tunnel for data.
<Provider>Wraps components to give them access.The broadcaster of data.
useContextHook to access data.The receiver of data.
Split ContextCreating separate Contexts for separate data.Fixes performance issues.

🛑 Stop and Think

1. If we wrap our entire application in a provider with a huge object, and change one tiny property, what happens?

Every single component that consumes (useContext) will re-render.

  • The Detail: Even if Component A only uses hugeObject.name and we only changed hugeObject.age, Component A will still re-render because the entire value object reference changed.
  • The Fix: This is why we often split context (e.g., UserContext separate from ThemeContext) or use state management libraries (Redux/Zustand) that allow components to select only the specific data they need.

2. What happens if we call useContext(UserContext) outside of a Provider?

It does not crash. It returns the Default Value you passed to createContext(defaultValue).

  • Example: const UserContext = createContext("Guest");
  • Result: useContext(UserContext) returns "Guest".
  • Gotcha: If initialized as createContext(null), our code might crash when we try to access user.name (reading property of null).

3. Why use Redux if Context is built-in?

Context is primarily for Prop Drilling (passing data), while Redux is for State Management.

  1. Performance: Context triggers re-renders for all consumers when the value changes. Redux allows components to subscribe to slices of the state, so they only update when their specific slice changes.
  2. Debugging: Redux has the Redux DevTools, which let us Time Travel (undo/redo actions), inspect every state change, and see a log of exactly what happened. Context lacks these built-in debugging superpowers.

4. If UserInfo is wrapped in React.memo, will it still re-render?

Yes. React.memo only checks if Props change. It does not stop re-renders caused by Context changes. If the context value updates, the component re-renders regardless of memo.

5. How do we fix this?

We need to Split the Context. instead of one giant AppProvider, we should have:

  1. UserProvider (Holds user state)
  2. ThemeProvider (Holds theme state) By separating them, updating the Theme will only trigger the ThemeProvider, leaving UserInfo (which listens to UserProvider) completely alone.