Solve React prop drilling with component composition
When we find ourselves in a prop drilling situation where we are passing props a few levels deep, we can solve the prop drilling with context or component composition.
Before using context to solve prop drilling, we should consider using component composition via the special children
prop. The children
prop makes it easy to compose and combine React components. Component composition is simpler to implement than context.
So, how does component composition help us with passing props down many levels? We'll find out how in an example below. First, we'll look at an example of prop drilling. Then, we'll see how context can solve the prop drilling. Lastly, we'll compare the context solution with a component composition solution to see which solution is better at solving the prop drilling.
Prop drilling
Let's take a look at the following example of prop drilling. It consists of a main App
component that displays a Dashboard
for logged in users. The Dashboard
receives a currentUser
prop that it doesn't use directly. Instead, it just passes the currentUser
prop along to its child components. This creates prop drilling.
import { useState } from "react";
type User = {
name: string;
};
const App = () => {
// global state that is usually put into context
const [currentUser, setCurrentUser] = useState<User>();
return (
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ background: "lightgray" }}>
<Header />
</div>
<div>
{currentUser ? (
<Dashboard user={currentUser} />
) : (
<Login onLogin={() => setCurrentUser({ name: "John" })} />
)}
</div>
<div style={{ background: "lightgray" }}>
<Footer />
</div>
</div>
)
}
export { App };
const Header = () => {
return (
<div>
<h1>Header</h1>
</div>
)
}
const Footer = () => {
return (
<div>
<h1>Footer</h1>
</div>
)
}
const Login = ({ onLogin }: { onLogin: () => void }) => {
return (
<div>
<h2>Login</h2>
<button onClick={onLogin}>Login</button>
</div>
)
}
const Dashboard = ({ user }: { user: User }) => {
return (
<div>
<h2>Dashboard</h2>
<DashboardNavigation />
<DashboardContent user={user} />
</div>
)
}
const DashboardNavigation = () => {
return (
<div>
<h3>Dashboard Navigation</h3>
</div>
)
}
const DashboardContent = ({ user }: { user: User }) => {
return (
<div>
<h3>Dashboard Content</h3>
<WelcomeMessage user={user} />
</div>
)
}
const WelcomeMessage = ({ user }: { user: User }) => {
return (
<div>
<p>Welcome, {user.name}.</p>
</div>
)
}
The Dashboard
and DashboardContent
components receive a user
prop, but both components don't actually use it. They just forward the user
prop to the WelcomeMessage
component. The first component in the component hierarchy that actually reads the user
data is the WelcomeMessage
component. At this level in the component hierarchy, we are nested several levels deep. The currentUser
state that is declared in the App
component does not get used until it is passed four levels deep into the WelcomeMessage
component.
Solving prop drilling with context
We can solve the prop drilling of the user
prop by putting the currentUser
state into React context. Then, we can retrieve the currentUser
from that context in the WelcomeMessage
component. Let's see what this context solution would look like.
Let's create a UserContext
and wrap the elements in the App
component within a context provider. Then, we'll remove the user
prop from the component hierarchy. Lastly, we'll use the useContext
Hook in the WelcomeMessage
component to retrieve the currentUser
from the UserContext
.
import { createContext, useContext, useState } from "react";
type User = {
name: string;
};
type UserContextType = {
currentUser: User | null;
};
const UserContext = createContext<UserContextType>({ currentUser: null });
const App = () => {
const [currentUser, setCurrentUser] = useState<User | null>(null);
return (
<UserContext.Provider value={{ currentUser }}>
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ background: "lightgray" }}>
<Header />
</div>
<div>
{currentUser ? (
<Dashboard />
) : (
<Login onLogin={() => setCurrentUser({ name: "John" })} />
)}
</div>
<div style={{ background: "lightgray" }}>
<Footer />
</div>
</div>
</UserContext.Provider>
)
}
export { App };
// ...
const Dashboard = () => {
return (
<div>
<h2>Dashboard</h2>
<DashboardNavigation />
<DashboardContent />
</div>
)
}
const DashboardContent = () => {
return (
<div>
<h3>Dashboard Content</h3>
<WelcomeMessage />
</div>
)
}
const WelcomeMessage = () => {
const { currentUser } = useContext(UserContext);
return (
<div>
<p>Welcome, {currentUser?.name}.</p>
</div>
)
}
Context fixes the prop drilling. There's no more drilling of props down multiple levels of components. The Dashboard
and DashboardContent
components have been simplified by removing their unnecessary user
props. The WelcomeMessage
now has direct access to the data that it needs.
The problem with this solution is that the WelcomeMessage
component cannot be rendered outside of the context provider for UserContext
. Rendering the WelcomeMessage
outside of the context provider will cause currentUser
to be null
, leaving us unable to access the actual value for currentUser
that the context provider makes available.
Another problem with this solution is that we need to check if the currentUser
is defined in the WelcomeMessage
component. We did this with JavaScript's optional chaining operator. However, if no currentUser
is defined, we should not even be rendering the WelcomeMessage
component. No welcome message is needed if no user is defined. It's assumed that we have a currentUser
when we render the WelcomeMessage
component, but that may not always be the case. We need more flexibility.
The complexity of context ends up trickling down the component hierarchy in the example above. This complexity can be avoided with component composition. Composition gives us the flexibility to be more explicit with our component usage.
Solving prop drilling with composition
The Dashboard
component is quite general for a component. It's hard to know what's inside it. It can be used to render lots of different things. It could render navigation, a sidebar, a content area, a welcome message, and more.
In it's current state, Dashboard
is not a composable component. We can't pick which elements we want to render as part of the dashboard. We want the Dashboard
component to be composable so that we can assemble various combinations of components to be rendered by it. This will allow us to pass the WelcomeMessage
, with the user
prop set, directly into the Dashboard
component. This will result in no more prop drilling.
The special children
prop is the key to component composition. Let's start from the initial example that suffered from prop drilling. Let's remove the user
prop from the component hierarchy. Then, let's add the special children
prop to the Dashboard
component and the DashboardContent
component. This will eliminate two levels of prop drilling. Lastly, let's compose what we want the Dashboard
to render by including the desired elements within the Dashboard
tags in the App
component.
import { ReactNode, useState } from "react";
type User = {
name: string;
};
const App = () => {
const [currentUser, setCurrentUser] = useState<User | null>(null);
return (
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ background: "lightgray" }}>
<Header />
</div>
<div>
{currentUser ? (
<Dashboard>
<DashboardNavigation />
<DashboardContent>
<WelcomeMessage user={currentUser} />
</DashboardContent>
</Dashboard>
) : (
<Login onLogin={() => setCurrentUser({ name: "John" })} />
)}
</div>
<div style={{ background: "lightgray" }}>
<Footer />
</div>
</div>
)
}
export { App };
// ...
const Dashboard = ({ children }: { children: ReactNode }) => {
return (
<div>
<h2>Dashboard</h2>
{children}
</div>
)
}
// ...
const DashboardContent = ({ children }: { children: ReactNode }) => {
return (
<div>
<h3>Dashboard Content</h3>
{children}
</div>
)
}
const WelcomeMessage = ({ user }: { user: User }) => {
return (
<div>
<p>Welcome, {user.name}.</p>
</div>
)
}
The Dashboard
and DashboardContent
components no longer need to receive a user
prop. The only component receiving the user
prop is the one that needs it, the WelcomeMessage
component.
The prop drilling is fixed. This solution needed just one prop, thanks to component composition via the children
prop. Using component composition to solve prop drilling is often simpler than using context.
The children
prop provides us with the flexibility to compose our dashboard in any way that we want. We can remove the DashboardNavigation
, or we can choose to include it. We get to choose what is rendered by the Dashboard
component.
Conclusion
When it comes to solving prop drilling in React, consider component composition before resorting to using React context.