Doing React the Right Way - 6/10

The Prop Drilling Problem and useContext Solution

As your React applications grow, you’ll encounter a common architectural challenge: how to share data between components that are separated by multiple levels in the component tree. Today, we’ll explore this problem through a realistic example and learn how React’s Context API provides an elegant solution.

This isn’t just about learning another hook—it’s about understanding component architecture and data flow patterns that scale from small applications to large, complex systems.

Understanding Prop Drilling Through Example

Let’s imagine a typical application structure where your main App component manages application-wide state like the current user and theme preferences. Deep in your component tree, you have components that need this data: a UserProfileHeader that displays the user’s name, and a ThemeToggleButton that needs both the current theme and a function to change it.

Your component hierarchy looks like this:

App (has user and theme data)
└── PageLayout (doesn't need the data)
    └── MainContent (doesn't need the data)
        ├── UserProfileHeader (needs user)
        └── ThemeToggleButton (needs theme and toggle function)

The challenge: PageLayout and MainContent are structural components—they don’t need user or theme information for their own rendering, but they’re forced to accept and pass along props they don’t use.

Implementing the Pattern

Let’s build this scenario to understand the implications:

// Types definition
interface User {
  name: string;
  email: string;
}

type Theme = "light" | "dark";

// Deep components that actually need the data
function UserProfileHeader({ user }: { user: User }) {
  return <h2>Welcome, {user.name}!</h2>;
}

function ThemeToggleButton({
  theme,
  toggleTheme,
}: {
  theme: Theme;
  toggleTheme: () => void;
}) {
  return (
    <button onClick={toggleTheme}>
      Switch to {theme === "light" ? "Dark" : "Light"} Mode
    </button>
  );
}

// Intermediate components that become unwilling messengers
function MainContent({
  user,
  theme,
  toggleTheme,
}: {
  user: User;
  theme: Theme;
  toggleTheme: () => void;
}) {
  return (
    <main>
      <UserProfileHeader user={user} />
      <ThemeToggleButton theme={theme} toggleTheme={toggleTheme} />
    </main>
  );
}

function PageLayout({
  user,
  theme,
  toggleTheme,
}: {
  user: User;
  theme: Theme;
  toggleTheme: () => void;
}) {
  return (
    <div className="layout">
      <header>My Application</header>
      <MainContent user={user} theme={theme} toggleTheme={toggleTheme} />
    </div>
  );
}

// The source of all state
function App() {
  const [user, setUser] = useState<User>({
    name: "John Developer",
    email: "john@example.com",
  });
  const [theme, setTheme] = useState<Theme>("light");

  const toggleTheme = () => {
    setTheme((current) => (current === "light" ? "dark" : "light"));
  };

  return (
    <div className={`app theme-${theme}`}>
      <PageLayout user={user} theme={theme} toggleTheme={toggleTheme} />
    </div>
  );
}

The Problems This Creates

This approach, while functional, creates several maintenance and performance issues:

Maintenance Complexity: When ThemeToggleButton needs additional user information, you must modify the prop interfaces of every intermediate component (MainContent and PageLayout) even though they don’t use this data.

Unclear Dependencies: Looking at PageLayout’s props, you can’t tell what it actually needs versus what it’s passing through. Its true responsibilities are obscured.

Performance Issues: When you change the theme, both PageLayout and MainContent re-render unnecessarily because their props changed, even though their visual output is identical. This cascades through the component tree, causing wasted work.

Tight Coupling: Intermediate components become tightly coupled to the data needs of their descendants, making them less reusable and harder to refactor.

The Context API: A Better Approach

React’s Context API provides a way to share data across component trees without explicitly passing props through every level. Think of it as a broadcast system—a parent component creates a “channel” and any descendant can tune into that channel directly.

Creating Context

First, we create our contexts in separate files for better organization:

// contexts/UserContext.tsx
import { createContext, useContext } from "react";

interface User {
  name: string;
  email: string;
}

const UserContext = createContext<User | null>(null);

// Custom hook for consuming the context
export function useUser() {
  const user = useContext(UserContext);
  if (!user) {
    throw new Error("useUser must be used within a UserProvider");
  }
  return user;
}

export const UserProvider = UserContext.Provider;
// contexts/ThemeContext.tsx
import { createContext, useContext } from "react";

type Theme = "light" | "dark";

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | null>(null);

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

export const ThemeProvider = ThemeContext.Provider;

Providing Context Values

Now we modify our App component to provide these contexts:

// App.tsx
import { useState } from "react";
import { UserProvider } from "./contexts/UserContext";
import { ThemeProvider } from "./contexts/ThemeContext";
import { PageLayout } from "./components/PageLayout";

function App() {
  const [user, setUser] = useState({
    name: "John Developer",
    email: "john@example.com",
  });
  const [theme, setTheme] = useState<Theme>("light");

  const toggleTheme = () => {
    setTheme((current) => (current === "light" ? "dark" : "light"));
  };

  const themeValue = { theme, toggleTheme };

  return (
    <ThemeProvider value={themeValue}>
      <UserProvider value={user}>
        <div className={`app theme-${theme}`}>
          <PageLayout />
        </div>
      </UserProvider>
    </ThemeProvider>
  );
}

Notice how PageLayout no longer receives any props related to user or theme data.

Consuming Context

Our intermediate components become much cleaner:

// components/PageLayout.tsx
import { MainContent } from "./MainContent";

export function PageLayout() {
  return (
    <div className="layout">
      <header>My Application</header>
      <MainContent />
    </div>
  );
}

// components/MainContent.tsx
import { UserProfileHeader } from "./UserProfileHeader";
import { ThemeToggleButton } from "./ThemeToggleButton";

export function MainContent() {
  return (
    <main>
      <UserProfileHeader />
      <ThemeToggleButton />
    </main>
  );
}

And our leaf components directly consume the context they need:

// components/UserProfileHeader.tsx
import { useUser } from "../contexts/UserContext";

export function UserProfileHeader() {
  const user = useUser();
  return <h2>Welcome, {user.name}!</h2>;
}

// components/ThemeToggleButton.tsx
import { useTheme } from "../contexts/ThemeContext";

export function ThemeToggleButton() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>
      Switch to {theme === "light" ? "Dark" : "Light"} Mode
    </button>
  );
}

The Benefits of This Approach

Clean Component APIs: PageLayout and MainContent now have no props related to global state. Their responsibilities are clear and focused.

Easy Maintenance: Adding new user or theme properties requires changes only to the context definition and the components that consume them. Intermediate components remain untouched.

Optimal Performance: When theme changes, only ThemeToggleButton re-renders because it’s the only component subscribed to the theme context. PageLayout and MainContent don’t re-render at all since their props didn’t change.

Loose Coupling: Components are decoupled from the data needs of their descendants, making them more reusable and easier to test.

Developer Experience: The custom hooks (useUser, useTheme) provide clear APIs and helpful error messages when used outside their providers.

Advanced Context Patterns

Multiple Context Providers

You can provide multiple contexts and even nest them:

function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <NotificationProvider>
          <Router>
            <Routes>
              <Route path="/" element={<HomePage />} />
              <Route path="/dashboard" element={<Dashboard />} />
            </Routes>
          </Router>
        </NotificationProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

Context with Complex State

For more complex state management, combine Context with useReducer:

// contexts/AppStateContext.tsx
import { createContext, useContext, useReducer } from "react";

interface AppState {
  user: User | null;
  theme: Theme;
  notifications: Notification[];
  isLoading: boolean;
}

type AppAction =
  | { type: "SET_USER"; payload: User | null }
  | { type: "SET_THEME"; payload: Theme }
  | { type: "ADD_NOTIFICATION"; payload: Notification }
  | { type: "SET_LOADING"; payload: boolean };

function appReducer(state: AppState, action: AppAction): AppState {
  switch (action.type) {
    case "SET_USER":
      return { ...state, user: action.payload };
    case "SET_THEME":
      return { ...state, theme: action.payload };
    case "ADD_NOTIFICATION":
      return {
        ...state,
        notifications: [...state.notifications, action.payload],
      };
    case "SET_LOADING":
      return { ...state, isLoading: action.payload };
    default:
      return state;
  }
}

interface AppContextType {
  state: AppState;
  dispatch: React.Dispatch<AppAction>;
}

const AppContext = createContext<AppContextType | null>(null);

export function useAppContext() {
  const context = useContext(AppContext);
  if (!context) {
    throw new Error("useAppContext must be used within AppProvider");
  }
  return context;
}

export function AppProvider({ children }: { children: React.ReactNode }) {
  const initialState: AppState = {
    user: null,
    theme: "light",
    notifications: [],
    isLoading: false,
  };

  const [state, dispatch] = useReducer(appReducer, initialState);

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

When to Use Context vs Props

Context isn’t a replacement for all props. Here are guidelines for when to use each:

Use Props when:

  • Data flows directly from parent to immediate child
  • The relationship is clear and intentional
  • You want to keep components explicitly connected to their data sources

Use Context when:

  • Multiple components at different tree levels need the same data
  • Intermediate components don’t use the data they’re passing
  • The data represents truly “global” application state (theme, user, language preferences)
  • You’re experiencing prop drilling pain

Performance Considerations

Context re-renders all components that consume it when the value changes. To optimize:

Split Contexts by Update Frequency: Separate frequently changing data from stable data:

// Stable user data
<UserProvider>
  {/* Frequently changing UI state */}
  <UIStateProvider>
    <App />
  </UIStateProvider>
</UserProvider>

Memoize Context Values: Prevent unnecessary re-renders by memoizing context values:

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");

  const value = useMemo(
    () => ({
      theme,
      toggleTheme: () => setTheme((t) => (t === "light" ? "dark" : "light")),
    }),
    [theme]
  );

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

Conclusion

The Context API transforms how we think about data flow in React applications. It eliminates prop drilling while maintaining React’s unidirectional data flow principles. When used appropriately, it leads to cleaner component hierarchies, better performance, and more maintainable code.

The key is recognizing when data truly needs to be “global” versus when explicit prop passing is more appropriate. Master this distinction, and you’ll architect React applications that scale gracefully from simple components to complex, feature-rich applications.

In our next article, we’ll explore advanced React patterns and performance optimization techniques that build on these architectural foundations.