Prop drilling in React: Solutions and trade-offs
Prop Drilling is the act of passing data, in this case, react props, through several nested layers of components before it reaches the component that needs the data.
Is this a problem in and of itself? It depends (which is the best answer for a question). Props are part of the “React way” of managing state, so using them, even passing them through nested components, isn’t necessarily bad. It depends on the scale.
For small applications, this is not an issue. You can use either of the approaches featured in this article or none. When a React app grows larger, Prop Drilling becomes an issue. It can make things hard to read, and it couples the parent and child components together, making it difficult to reuse components. When does a small app become a large one? Good question, moving on.
Let's set up a scenario to better understand what's happening. Let's say we have an app that is set up something like this image.
The Data Page component is fetching our data to be used. Part of that data (tags, prices, attributes, etc.) needs to be used by our filter and sort buttons and their respective modal/form/popover/etc. That code might look something like this…
DataPage (){
const [filterData, setFilterData] = useState({})
// ...
return
(
<OptionsBar MetaData={filterData} />
<DataArea />
)
}
// OptionsBar.jsx
OptionsBar({filterData}){
// ...
return (
<SearchBar />
<FilterAndSort filterData={filterData}/>
)
}
// FilterAndSort.jsx
FilterAndSort({}){
// ...
return (
<Filter filterData={filterData} />
)
}
// Filter.jsx
Filter({filterData}){
// ...
return(
<>
<Button>Filter</Button>
<Modal
content={<FilterForm filterForm={filterData}/>}
/>
<>
)
}
As you can see, the filterData
prop gets passed through several intermediate components before it eventually gets used in the filterForm
.
Enter Context:
One way to solve the problem of prop drilling is to use React’s built-in contextAPI. Creating a context provider allows every child of that provider, regardless of how deeply nested it is, access to the context data. That implementation might look something like this.
// App.jsx
import { createContext, useContext } from "react";
DataPage(){
const [filterData, setFilterData] = useState({})
// Create Context
const dataContext = createContext();
...
return (
<dataContext.Provider value={{data: filterData}}>
<OptionsBar />
<DataArea />
</dataContext.Provider>
)
}
// Filter.jsx
export Filter({}){
const { data } = useContext(dataContext)
return(
<>
<Button>Filter</Button>
<Modal
content={<FilterForm filterForm={data}/>}
/>
<>
)
}
By using context, we skip over all the intermediate components and access our data exactly where we need it.
Problem solved! Reacts own documentation suggests using context to solve the issue of prop drilling, "Using context, we can avoid passing props through intermediate elements"4. With how easy that solution was, it will be tempting to use context everywhere. This is where we can run into some issues. Let's say we add a userContex
, themeContext
, and a authContext
and end up with a render that looks something like this.
Now, we have contexts inside of other contexts, leading to unnecessary complexity and confusion. Tracking down bugs or making updates can become a chore with a touch of hide and seek. "Was userLanguage
in authentication or userData?" It is also important to remember that using context means that any component and/or their children that access the context data are tightly coupled, thus reducing its re-usability.
There is another caveat to using context. This is especially true when the data in the context provider is an object. If a value in that object gets updated, react will replace the whole object with a new one1. This will trigger a re-render on all components using that context and possibly their children. So if that update happens to be on a high-level parent component or a root level one, it could potentially re-render the entire page.
React's documentation offers a warning about the use of context.
Context is very tempting to use! However, this also means it’s too easy to overuse it. Just because you need to pass some props several levels deep doesn’t mean you should put that information into context.1
and again
Before You Use Context
Context is primarily used when some data needs to be accessible by many components at different nesting levels. Apply it sparingly because it makes component reuse more difficult.>If you only want to avoid passing some props through many levels, component composition is often a simpler solution than context.4
Component Composition:
As seen above, React’s documentation suggests that "Component Composition" is a better solution. In other words, put your components in areas where they can access the props they need while skipping over the intermediaries. You can do this by lifting components up in the tree and using child components as props.
There are a couple different approaches to component composition. One way we could do this is by moving our components up the tree and wrapping them inside of the parent component. In the example below, we could move all of our imports up to the DataPage
and wrap them inside of each other allowing the filterData
prop to be passed directly to the Filter
component.
// DataPage.jsx
DataPage(){
const [filterData, setFilterData] = useState({})
// ...
return (
<>
<OptionsBar>
<FilterAndSort>
<Filter filterData={filterData}/>
</FilterAndSort>
</OptionsBar>
<DataArea />
</>
)
}
// OptionsBar.jsx
OptionsBar({ children }){
// ...
return (
// ...
{ children }
)
}
// FilterAndSort.jsx
FilterAndSort({children}){
// ...
return (
// ...
{ children }
)
}
// Filter.jsx
Filter({filterData}){
// ...
return(
<>
<Button>Filter</Button>
<Modal
content={<FilterForm filterForm={filterData}/>}
/>
<>
)
}
Admittedly, this is an extreme example and could impact the readability, size, and complexity of our parent component. Luckily, we have flexibility with how we compose our components! We could instead move the wrapping one level down into the OptionsBar
component to make things more readable.
// DataPage.jsx
DataPage(){
const [filterData, setFilterData] = useState({})
// ...
return (
<>
<OptionsBar filterData={filterData}/>
<DataArea />
</>
)
}
// OptionsBar.jsx
OptionsBar({filterData}){
// ...
return (
<Search />
<FilterAndSort>
<Filter filterData={filterData}/>
</FilterAndSort>
)
}
Another approach would be to alter our components to have props that take components. This way we can set our component up in the parent, pass our props to it, and then pass that whole component as a prop.
// OptionsBar.jsx
OptionsBar({filterData}){
const filter = <Filter filterData={filterData} />
...
return (
<Search />
<FilterAndSort filter={filter} sort={...} />
)
}
// FilterAndSort.jsx
FilterAndSort({filter, sort}){
return (
<div>
{ filter }
{ sort }
</div>
)
}
Recap:
So, context can solve the issue of prop drilling by allowing data to be accessed right where we need it. But, it comes with issues of making components hard to reuse and unnecessary re-renders. Component Composition is a better solution. We can retain the flexibility, re-usability, and readability of components. But, it can lead to large and complex parent components depending on how you compose your components (see what I did there). Of course, you are not limited to choosing just one or the other. The best solution for your app may be a mix of both.
References:
1. https://react.dev/reference/react/useContext
2. https://react.dev/learn/passing-data-deeply-with-context
3. https://legacy.reactjs.org/docs/composition-vs-inheritance.html
4. https://legacy.reactjs.org/docs/context.html