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.