Solve React prop drilling with component composition

Learn how to solve prop drilling in React by using component composition. Find out why it's a better solution than React context.
May 13, 2023React
8 min read

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.

App.tsx
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.

App.tsx
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.

App.tsx
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.

New
Be React Ready

Learn modern React with TypeScript.

Learn modern React development with TypeScript from my books React Ready and React Router Ready.