The Pain of Prop Drilling: Solutions for a Smoother React Experience

The Problem: Prop Drilling

React’s incredible ability to keep multiple parts of the UI in sync is a double-edged sword. As your application grows, so does the complexity of passing props and event handlers down the component hierarchy. This phenomenon, known as prop drilling, can lead to tedious and error-prone code.

Consider a simple React app with four components: App, LeftColumn, RightColumn, and ACounter. The App component owns the state and renders the two columns, which in turn render the counter. To pass state and event handlers down to the counter, we need to add props to every parent component.

function App() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <LeftColumn>
        <RightColumn>
          <ACounter count={count} onIncrement={() => setCount(count + 1)} />
        </RightColumn>
      </LeftColumn>
    </div>
  );
}

This gets even worse when using TypeScript, as we need to add types for the prop or event to every component in the hierarchy.

Solution 1: Use Fewer Components

One approach is to eliminate intermediate components and their props, reducing the need for prop drilling. This method, popularized by Kent C. Dodds, yields concise and clear code with minimal type annotations.

function App() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <ACounter count={count} onIncrement={() => setCount(count + 1)} />
    </div>
  );
}

However, it can make it harder to reuse components and removes possibilities for memoization.

Solution 2: Use Children

By using React’s children prop, we can colocate the counters and the state while keeping reusable intermediate components. This approach allows us to pass children as props, making it an incredibly powerful tool for composition.

function App() {
  const [count, setCount] = useState(0);

  return (
    <LeftColumn>
      <RightColumn>
        {children}
      </RightColumn>
    </LeftColumn>
  );
}

function ACounter() {
  return (
    <App>
      <p>Count: {count}</p>
      <button onClick={onIncrement}>Increment</button>
    </App>
  );
}

Solution 3: Combine Related Props into a Single Object

Combining related props into a single object reduces repetition when adding or changing properties.

interface CounterProps {
  count: number;
  onIncrement: () => void;
}

function App() {
  const [count, setCount] = useState(0);

  return (
    <LeftColumn>
      <RightColumn>
        <ACounter {...{ count, onIncrement: () => setCount(count + 1) }} />
      </RightColumn>
    </LeftColumn>
  );
}

However, it can result in more complex event handlers and may lead to “overforwarding” if a component doesn’t need all the properties in the object.

Solution 4: Use the Context API

React’s Context API provides a form of dependency injection, allowing a parent component to provide an object and a child component to consume it without intermediate components needing to know about it.

const CounterContext = createContext();

function App() {
  const [count, setCount] = useState(0);

  return (
    <CounterContext.Provider value={{ count, onIncrement: () => setCount(count + 1) }}>
      <LeftColumn>
        <RightColumn>
          <ACounter />
        </RightColumn>
      </LeftColumn>
    </CounterContext.Provider>
  );
}

function ACounter() {
  return (
    <CounterContext.Consumer>
      {({ count, onIncrement }) => (
        <>
          <p>Count: {count}</p>
          <button onClick={onIncrement}>Increment</button>
        </>
      )}
    </CounterContext.Consumer>
  );
}

While it directly solves the issue of threading props, it makes dependencies less explicit and prevents TypeScript from tracking them.

Solution 5: Pick Props

This strategy recognizes that most props passed around an application aren’t unique. By defining types for the application state and event handlers, we can “pick” props off that when defining individual components.

interface AppState {
  count: number;
}

interface AppEventHandlers {
  onIncrement: () => void;
}

function App() {
  const [count, setCount] = useState(0);

  return (
    <LeftColumn>
      <RightColumn>
        <ACounter {...{ count, onIncrement: () => setCount(count + 1) }} />
      </RightColumn>
    </LeftColumn>
  );
}

function ACounter({ count, onIncrement }: Pick<AppState & AppEventHandlers, 'count' | 'onIncrement'>) {
  return (
    <>
      <p>Count: {count}</p>
      <button onClick={onIncrement}>Increment</button>
    </>
  );
}

This approach retains explicit dependencies without having to modify intermediate components, but requires some fancy TypeScript constructs.

Leave a Reply