React & Next.js Topic

React Component Architecture & Props Drilling Solutions

The Problem: Prop Drilling

Prop drilling occurs when you need to pass data through multiple layers of components that don't need the data themselves, just to get it to a deeply nested component that does need it.

jsx

// Example of Prop Drilling
function App() {
  const [user, setUser] = useState({ name: "John", id: 1 });
  
  return (
    <div>
      {/* Header doesn't use user, but must pass it through */}
      <Header user={user} />
    </div>
  );
}

function Header({ user }) {
  return (
    <header>
      {/* Navigation doesn't use user, but must pass it through */}
      <Navigation user={user} />
    </header>
  );
}

function Navigation({ user }) {
  return (
    <nav>
      {/* UserProfile actually NEEDS the user data */}
      <UserProfile user={user} />
    </nav>
  );
}

function UserProfile({ user }) {
  return <div>Welcome, {user.name}!</div>; // Finally uses the data
}

This creates:

  • Brittle components - changing data flow requires modifying intermediate components

  • Poor maintainability - hard to track where data comes from and goes

  • Unnecessary re-renders - intermediate components re-render even when they don't use the data


Solution 1: Component Composition

Component composition is the practice of building components by combining smaller, more focused components. It's often the simplest and most effective solution to avoid prop drilling.

Using children Prop

jsx

// Before: Prop drilling
function App() {
  const [user] = useState({ name: "John" });
  return <Header user={user} />;
}

function Header({ user }) {
  return (
    <header>
      <Logo />
      <Navigation user={user} />
    </header>
  );
}

// After: Using composition
function App() {
  const [user] = useState({ name: "John" });
  return (
    <Header>
      <Navigation>
        <UserProfile user={user} />
      </Navigation>
    </Header>
  );
}

function Header({ children }) {
  return (
    <header>
      <Logo />
      {children} {/* Navigation gets rendered here */}
    </header>
  );
}

function Navigation({ children }) {
  return (
    <nav>
      {children} {/* UserProfile gets rendered here */}
    </nav>
  );
}

function UserProfile({ user }) {
  return <div>Welcome, {user.name}!</div>;
}

Using "Slot" Props

When you need more control over multiple insertion points:

jsx

function Card({ header, content, footer }) {
  return (
    <div className="card">
      <div className="card-header">{header}</div>
      <div className="card-content">{content}</div>
      <div className="card-footer">{footer}</div>
    </div>
  );
}

function App() {
  const [user] = useState({ name: "John" });
  
  return (
    <Card
      header={<UserAvatar user={user} />}
      content={<UserDetails user={user} />}
      footer={<Actions user={user} />}
    />
  );
}

// Each of these components receives user directly from App
// No prop drilling through Card!

When to use Composition:

  • When you have a few levels of nesting

  • When the relationship between components is clear and direct

  • For reusable container components


Solution 2: Lifting State Up

"Lifting state up" means moving shared state to the closest common ancestor of components that need it.

Example: Temperature Calculator

jsx

// Problem: Two inputs need to stay in sync
function BoilingVerdict({ celsius }) {
  return <p>{celsius >= 100 ? "Water would boil" : "Water would not boil"}</p>;
}

function App() {
  // State lifted up to the common parent
  const [temperature, setTemperature] = useState('');
  const [scale, setScale] = useState('c');

  function handleCelsiusChange(temp) {
    setTemperature(temp);
    setScale('c');
  }

  function handleFahrenheitChange(temp) {
    setTemperature(temp);
    setScale('f');
  }

  const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature;
  const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;

  return (
    <div>
      <TemperatureInput
        scale="c"
        temperature={celsius}
        onTemperatureChange={handleCelsiusChange}
      />
      <TemperatureInput
        scale="f"
        temperature={fahrenheit}
        onTemperatureChange={handleFahrenheitChange}
      />
      <BoilingVerdict celsius={parseFloat(celsius)} />
    </div>
  );
}

function TemperatureInput({ scale, temperature, onTemperatureChange }) {
  function handleChange(e) {
    onTemperatureChange(e.target.value);
  }

  return (
    <fieldset>
      <legend>Enter temperature in {scale === 'c' ? 'Celsius' : 'Fahrenheit'}:</legend>
      <input value={temperature} onChange={handleChange} />
    </fieldset>
  );
}

When to lift state up:

  • When multiple sibling components need the same data

  • When components need to stay synchronized

  • When the state relationship is localized to a specific section


Solution 3: Context API

Context provides a way to pass data through the component tree without having to pass props down manually at every level. It's ideal for "global" data that many components need.

Creating and Using Context

jsx


// Custom Hook for using the context
function useUser() {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error('useUser must be used within a UserProvider');
  }
  return context;
}

When to Use Context API:

  • Theme data (light/dark mode)

  • User authentication status

  • Preferred language (i18n)

  • Global loading states

  • Data that's needed in many places far apart in the component tree


Choosing the Right Solution

Solution Best For Complexity Performance
Component Composition Local component relationships, reusable containers Low Excellent
Lifting State Up Sibling components that need to share state Low-Medium Good
Context API Global data, deeply nested components needing same data Medium Good (with careful use)

Practical Decision Flow:

  1. Start with local state - keep state as close as possible to where it's used

  2. Lift state up when siblings need to share it

  3. Use composition when you have wrapper components or clear parent-child relationships

  4. Use Context for truly global data that's needed in many unrelated components

  5. Consider state management libraries (Redux, Zustand) only when the above aren't sufficient for complex applications

Advanced Pattern: Compound Components with Context

jsx

// Powerful pattern for flexible, connected components
const MenuContext = createContext();

function Menu({ children, onSelect }) {
  const [selectedItem, setSelectedItem] = useState(null);

  const contextValue = {
    selectedItem,
    onSelect: (item) => {
      setSelectedItem(item);
      onSelect?.(item);
    }
  };

  return (
    <MenuContext.Provider value={contextValue}>
      <ul className="menu">{children}</ul>
    </MenuContext.Provider>
  );
}

function MenuItem({ children, value }) {
  const { selectedItem, onSelect } = useContext(MenuContext);
  const isSelected = selectedItem === value;

  return (
    <li 
      className={isSelected ? 'selected' : ''}
      onClick={() => onSelect(value)}
    >
      {children}
    </li>
  );
}

// Usage - components work together seamlessly
function App() {
  return (
    <Menu onSelect={(item) => console.log('Selected:', item)}>
      <MenuItem value="home">Home</MenuItem>
      <MenuItem value="about">About</MenuItem>
      <MenuItem value="contact">Contact</MenuItem>
    </Menu>
  );
}

The key is to use the simplest solution that solves your problem. Start with composition and local state, and only reach for more complex solutions when you have a clear need.

Comments (0)

No comments yet. Be the first to comment!

LecturreAI Assistant

Hello! I'm LecturreAI

What would you like to learn today?