Back to Insights
Engineering

React Component Communication: Props, Lifting State & Context API

Learn how data flows between React components, why prop drilling becomes a problem at scale, and how the Context API solves it — with real-world B2B SaaS examples and TypeScript patterns we use at Atonize.

React Component Communication: Props, Lifting State & Context API

TL;DR — Key Takeaways

  • Data in React flows downward — from parent to child via props
  • When sibling components need to share state, lift it up to their common ancestor
  • Passing props through many layers of components is called prop drilling — it's painful and doesn't scale
  • The Context API solves prop drilling by making data available to any component in a subtree
  • Context is not a replacement for proper state management — use it for genuinely global data (theme, auth, locale)

How Data Flows in React

React has a strict, predictable data flow: top-down, parent to child. A parent component passes data to its children via props. Children can't push data back up — they can only call functions that the parent passed down.

This constraint is a feature, not a bug. It makes applications easier to reason about, debug, and test. But it creates a real problem as your component tree grows.


Lifting State Up: The First Solution

Imagine you're building a product filter page for a B2B catalog. A SearchBar component captures the user's query, and a ProductGrid component displays filtered results. Both are children of a CatalogPage. How does the search query get from SearchBar to ProductGrid?

The answer is lifting state up — move the state to the closest common ancestor.

// app/catalog/page.tsx
"use client";
 
import { useState } from "react";
import { SearchBar } from "@/components/SearchBar";
import { ProductGrid } from "@/components/ProductGrid";
 
export default function CatalogPage() {
  const [query, setQuery] = useState("");
 
  return (
    <div className="mx-auto max-w-6xl px-4 py-8">
      <SearchBar value={query} onChange={setQuery} />
      <ProductGrid query={query} />
    </div>
  );
}
// components/SearchBar.tsx
interface SearchBarProps {
  value: string;
  onChange: (value: string) => void;
}
 
export function SearchBar({ value, onChange }: SearchBarProps) {
  return (
    <input
      type="search"
      value={value}
      onChange={(e) => onChange(e.target.value)}
      placeholder="Search products..."
      className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
    />
  );
}

CatalogPage owns the state. SearchBar receives it as a prop and calls onChange when the user types. ProductGrid receives query as a prop and filters accordingly. Clean, traceable, testable.

This works beautifully — until your component tree gets deep.


The Prop Drilling Problem

As applications grow, you often need to pass data through components that don't actually use it — they just relay it further down. This is prop drilling.

// ❌ UserAvatar needs `userId` but Layout and Sidebar don't care about it
<AppLayout userId={userId}>
  <Sidebar userId={userId}>
    <NavigationMenu userId={userId}>
      <UserAvatar userId={userId} />
    </NavigationMenu>
  </Sidebar>
</AppLayout>

Every intermediate component becomes coupled to data it doesn't use. Adding or removing a prop means touching every layer. This is the problem Context API was designed to solve.


The Context API: Global Data Without the Drilling

Context lets you broadcast data to any component in a subtree without threading it through every level.

Here is a real-world pattern we use at Atonize: an authentication context that makes the current user available anywhere in the application.

Step 1: Create the context

// context/AuthContext.tsx
"use client";
 
import { createContext, useContext, useState, ReactNode } from "react";
 
interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "manager" | "viewer";
}
 
interface AuthContextValue {
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
}
 
const AuthContext = createContext<AuthContextValue | null>(null);
 
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
 
  function login(user: User) {
    setUser(user);
  }
 
  function logout() {
    setUser(null);
  }
 
  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}
 
export function useAuth(): AuthContextValue {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
}

Step 2: Wrap your app with the provider

// app/layout.tsx
import { AuthProvider } from "@/context/AuthContext";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AuthProvider>
          {children}
        </AuthProvider>
      </body>
    </html>
  );
}

Step 3: Consume anywhere, no drilling required

// components/UserAvatar.tsx
import { useAuth } from "@/context/AuthContext";
 
export function UserAvatar() {
  const { user, logout } = useAuth();
 
  if (!user) return null;
 
  return (
    <div className="flex items-center gap-3">
      <div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-sm font-medium text-white">
        {user.name.charAt(0)}
      </div>
      <div className="hidden md:block">
        <p className="text-sm font-medium">{user.name}</p>
        <p className="text-xs text-gray-500 capitalize">{user.role}</p>
      </div>
      <button
        onClick={logout}
        className="text-xs text-gray-400 hover:text-gray-600"
      >
        Sign out
      </button>
    </div>
  );
}

UserAvatar gets the user data directly from context. AppLayout, Sidebar, and NavigationMenu don't need to know about userId at all.


When to Use Context (and When Not To)

Context is powerful, but it has a cost: every component that consumes a context re-renders when that context value changes. Use it for data that is truly global and changes infrequently.

Good use cases for ContextBad use cases for Context
Current authenticated userA list of products (changes often)
Active theme (light/dark)UI state specific to one feature
Current locale / languageServer data (use SWR or TanStack Query)
Feature flagsFrequently updating values

For frequently changing data shared across many components — think a shopping cart that updates on every add/remove, or a real-time notification count — you'll want a dedicated state management library. We cover Zustand and its advantages over Redux in Global State Management in 2026.


Common Pitfalls

1. One giant context for everything

// ❌ Avoid — one change in any part re-renders everything consuming this
const AppContext = createContext({ user, theme, cart, notifications, filters });
 
// ✅ Split by domain — components subscribe only to what they need
const AuthContext = createContext({ user });
const ThemeContext = createContext({ theme });
const CartContext = createContext({ cart });

2. Missing the null guard in custom hooks

Always throw a descriptive error when the context hook is used outside its provider. This saves hours of debugging:

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within <AuthProvider>");
  }
  return context;
}

3. Putting context value objects inline

// ❌ Creates a new object on every render, causing all consumers to re-render
<AuthContext.Provider value={{ user, login, logout }}>
 
// ✅ Memoize the value (or keep it stable via useState/useReducer)
const value = useMemo(() => ({ user, login, logout }), [user]);
<AuthContext.Provider value={value}>

We dive deeper into memoization strategies in Advanced React Hooks: useMemo, useCallback & useRef.


The Full Communication Toolkit

At this point in the series, you have the full picture of local communication patterns:

  • Props — for direct parent-to-child data flow
  • Lifting state — for sibling components sharing a common ancestor
  • Callback props — for child-to-parent communication (onChange, onSubmit)
  • Context API — for subtree-wide data without drilling

When these aren't enough — when you need state shared across completely disconnected parts of the app, or when you need fine-grained subscriptions — that's when global state management enters the picture. We tackle that next in Global State Management in 2026.


Frequently Asked Questions (FAQ)

What is prop drilling and why is it a problem?

Prop drilling is the pattern of passing props through multiple layers of components that don't use the data themselves — they only pass it further down. It's a problem because it creates unnecessary coupling: every intermediate component must be updated if the prop changes, making the codebase harder to refactor and maintain.

Is Context API a state management solution?

Not exactly. Context is a data distribution mechanism — it makes data available across a component tree. State management is about how data is stored, updated, and derived. You still need useState or useReducer to manage the actual state; Context just removes the need to drill it as props.

Does using Context cause performance issues?

It can. Every component that consumes a context value re-renders when that value changes. For high-frequency updates (scroll position, real-time data), this can become expensive. Mitigation strategies include splitting contexts by domain, memoizing context values, and using external state libraries like Zustand that offer more granular subscriptions.

When should I use Context vs. a state management library like Zustand?

Use Context for low-frequency, truly global data: auth, theme, locale. Use Zustand (or a similar library) when you need: state shared between many disconnected components, frequent updates without re-rendering everything, or more complex state logic with actions and derived values.


Next in the Atonize React series: Advanced React Hooks: useMemo, useCallback & useRef — performance optimization patterns we apply on every production project.