What's inside
In the first part of my blog post series about the methods of avoiding prop-drilling in React, I showed that prop-drilling is generally beneficial and you may usually want it in your applications.
I then presented a way for refactoring your components using Inversion of Control principle to limit the number of passed props when their volume becomes overwhelming. In this part, we will see how to use the same strategy in a more complex situation, boosting it with render props. We will then consider an example of an application where a piece of data needs to be passed to almost every leaf component and instead of Redux, we will use React Context to achieve that.
Using render props to limit prop drilling
A variation of the Inversion of Control-based approach can be also used in more complicated situations, when the nested component requiring data from up high (like SearchBar in our last example) needs to receive some additional information from the parent into which it’s "injected". We will now consider such a case.
Our next example has a different component structure - have a look below. The arrows point to components that need access to the same piece of data.
App Basket <-- AllProductsList ProductList Product <-- Product <-- Product <-- ...
This example renders a list of products, each with an "Add to basket" button. After we add different products to the basket, their total number is displayed in Basket component.
We need to put the state describing current products in the basket in a common ancestor of Product and Basket components, which is App again. Below you can see the full source code. Note that the state of AllProductsList component would normally be populated e.g. by making a request to some backend, while in our case it's hardcoded for simplicity.
Here’s the live demo: https://codesandbox.io/s/vyk028p58y
class App extends Component { constructor () { super() this.state = { productsInBasket: {} } }
addToBasket = (id) => { this.setState(prevState => ({ productsInBasket: { ...prevState.productsInBasket, [id]: prevState.productsInBasket[id] + 1 || 1 } })) }
countProductsInBasket = () => ( Object.keys(this.state.productsInBasket).reduce( (total, key) => total + this.state.productsInBasket[key], 0 ) )
render () { return ( <div className="App"> <Basket productCount={this.countProductsInBasket()}/> <AllProductsList handleBuyClick={this.addToBasket} productsInBasket={this.state.productsInBasket}/> </div> ) } }
const Basket = ({productCount}) => ( <div className="Basket">Basket ({productCount})</div> )
class AllProductsList extends Component { constructor () { super() this.state = { products: [{id: 21, name: 'Pikachu Mascot'}, {id: 7, name: 'Gloomhaven'}] } }
render () { return ( <ProductList products={this.state.products} handleBuyClick={this.props.handleBuyClick} productsInBasket={this.props.productsInBasket}/> ) } }
const ProductList = ({products, productsInBasket, handleBuyClick}) => ( products.map(({id, name}) => ( <Product key={id} id={id} name={name} count={productsInBasket[id] || 0} handleBuyClick={handleBuyClick}/> )) )
const Product = ({id, name, count, handleBuyClick}) => ( <div className="Product"> <h3>{name}</h3> <button onClick={() => handleBuyClick(id)}>Buy ({count} in basket)</button> </div> )
In order for every instance of Product component to be able to add a product to basket when a button is clicked, we send it a handleBuyClick prop through AllProductsList and ProductList components.
We also want to show in the Product component the number of pieces of a given product that are already in the basket. Because of that, we pass productsInBasket from App to AllProductsList and further to ProductList which then renders Product component instances sending them info about their amount in basket through count prop.
We'd like to use the Inversion of Control strategy from the first example and instantiate Product in App where it could be given values for its handleBuyClick and count props, and be passed down the component tree to be rendered many times inside ProductList, reducing the need to pass these two props through AllProductsList and ProductList components.
Let's try to do that:
class App extends Component { //...
render () { const product = // ooops, what to pass in place of question marks? <Product id={?} name={?} count={this.state.productsInBasket[?]} handleBuyClick={this.addToBasket}/> return ( <div className="App"> <Basket productCount={this.countProductsInBasket()}/> <AllProductsList render={product}/> </div> ) } }
The thing is, we can't do that. Every Product component instance also requires name and id props, but these details are not known in App - they're known in ProductList. What’s more, the count prop depends on id, too, so can’t be resolved inside App. The things known in App are this.addToBasket function and this.state.productsInBasket array which are also needed to provide `Product` with props, but don’t let us sastisfy all of them.
Wouldn't it be nice if there would be a way to instantiate the Product partially and provide it with stuff to which App has direct access, and finish the instantiation in ProductList later, already having access to the right name and id?
Well, there is a way to do that - you can use a render prop.
Render prop
A render prop is a prop which is a function that - received by a component - is executed by this component and its result is what the component renders. You can read more about render props in React docs.
Here is how we could use it in our case:
class App extends Component { //...
render () { const product = (id, name) => <Product key={id} id={id} name={name} count={this.state.productsInBasket[id] || 0} handleBuyClick={this.addToBasket}/> return ( <div className="App"> <Basket productCount={this.countProductsInBasket()}/> <AllProductsList render={product}/> </div> ) } }
class AllProductsList extends Component { //...
render () { return ( <ProductList products={this.state.products} render={this.props.render}/> ) } }
const ProductList = ({products, render}) => ( products.map(({id, name}) => ( render(id, name) )) )
const Product = ({id, name, count, handleBuyClick}) => ( <div className="Product"> <h3>{name}</h3> <button onClick={() => handleBuyClick(id)}>Buy ({count} in basket)</button> </div> )
Instead of passing handleBuyClick and productsInBasket props, the intermediate components pass a single prop called render now (the name could be anything). App sets its value to be a function, accepting id and name parameters and returning a Product instance, provided with dependencies available inside App's render method. When the function reaches ProductList, the component executes it for every product received from "backend", passing the right arguments known at this level. The Product instance returned from the function now has all the props it requires. Thanks to this approach, we have limited the number of props passed through AllProductsList and ProductList components.
The updated live version of our example using a render prop is available here: https://codesandbox.io/s/q8wj8mrqq9
Global variables
Almost every app has a piece of data it needs to read in numerous nodes of a component tree. This is especially annoying because to get the data to these nodes, we need to pass the same prop through the majority of components in the application, adding a lot of noise to its code. An example of such data is the id of the currently authenticated user or an application setting applicable globally (e.g., theme, locale). Another popular case would be a list of notifications added by many components. For such a list to work, we need to pass a function prop that would allow adding notifications to the list to all the components that would like to do that.
Let's say we have an application like Reddit that shows different stuff posted by users. It makes use of react-router library and consists of a few pages: one of them shows the latest posts, another one presents the most upvoted posts (“top posts”), and the last one contains posts that got many upvotes only recently (let's call them "hot").
If the user is logged out, we only show 20% of the text in each post. Once they log in, a post’s content is presented in its entirety. The application also has a simple “contact” page, with a contact form and fake message sending code. If the user is logged in, their id is added to the message.
Here’s a live demo: https://codesandbox.io/s/kmzyj915j5
And the source code:
class App extends Component {
constructor () {
super()
this.state = { userId: null }
} logIn = () => {
this.setState({userId: 15})
} logOut = () => {
this.setState({userId: null})
} render () {
const {userId} = this.state return (
<Router>
<div className="App">
<Header showLogOut={userId !== null} handleLogInClick={this.logIn} handleLogOutClick={this.logOut}/>
<Route exact path="/" render={() => <LatestPostList showExcerpts={userId === null}/>}/>
<Route path="/hot" render={() => <HotPostList showExcerpts={userId === null}/>}/>
<Route path="/top" render={() => <TopPostList showExcerpts={userId === null}/>}/>
<Route path="/contact" render={() => <Contact userId={userId}/>}/>
</div>
</Router>
)
}
} const Header = ({showLogOut, handleLogInClick, handleLogOutClick}) => (
<div className="Header">
<Logo/>
<HeaderMenu showLogOut={showLogOut} handleLogInClick={handleLogInClick} handleLogOutClick={handleLogOutClick}/>
</div>
) const Logo = () => <div className="Logo">ð</div> const HeaderMenu = ({showLogOut, handleLogOutClick, handleLogInClick}) => (
<ul className="HeaderMenu">
<li><Link to="/">Latest</Link></li>
<li><Link to="/hot">Hot</Link></li>
<li><Link to="/top">Top</Link></li>
<li><Link to="/contact">Contact</Link></li>
<li>
{showLogOut ? (
<button onClick={handleLogOutClick}>Log out</button>
) : (
<button onClick={handleLogInClick}>Log in</button>
)}
</li>
</ul>
) class LatestPostList extends Component {
constructor () {
super()
// normally we would fetch latest posts from server
this.state = {
latestPosts: [{id: 185, content: 'Recent post 1'}, {id: 184, content: 'Recent post 2'}]
}
} render () {
return <PostList posts={this.state.latestPosts} showExcerpts={this.props.showExcerpts}/>
}
} class HotPostList extends Component {
// analogous to LatestPostList
} class TopPostList extends Component {
// analogous to LatestPostList
} const PostList = ({posts, showExcerpts}) => (
posts.map(post => (
<Post key={post.id} content={post.content} showExcerpt={showExcerpts}/>
))
) const Post = ({content, showExcerpt}) => (
<div className="Post">
{showExcerpt ? content.substring(0, content.length \* 0.2) + '... (log in to see all)' : content}
</div>
) const Contact = ({userId}) => { let content = null return ( <form className="Contact" onSubmit={e => { e.preventDefault(); // fake message sending: console.log('Sending message: ' + content.value + ', from: ' + (userId !== null ? userId : 'guest')) }}> <textarea name="content" ref={input => content = input} /> <div><button type="submit">Send</button></div> </form> ) }
In the code above, if a user logs in, to simulate it the userId property in App's state is set to 15, and on logout it changes to null. HeaderMenu, Contact and Post instances depend on this data.
How exactly? HeaderMenu accepts a showLogOut prop, being true if userId is not null, in order to render “Log out” button if somebody’s authenticated or “Log in” button otherwise. As for Contact, userId is passed to it directly and attached to the body of the message sent by the user.
Finally, the Post components need to know if userId is null, to decide between showing an excerpt of a post or its full content. This information comes from App‘s state to each Post through intermediate components, in the form of showExcerpt prop.
When App renders LatestPostList, HotPostList and TopPostList, it needs to tell all of them if userId is null or not, because each has a Post as a descendant and needs to transport that information to it.
Ultimately, the user’s id and props derived from it reach nearly every component in the application. At the same time, similarly to the previous examples, only some of them really need it - the others only pass them down to children.
We could use our Inversion of Control strategy again but instantiating all the components that depend on userId in App would quickly make it too complex, especially when adding new pages to our application (which are likely to also depend on userId).
It still doesn’t mean you have to reach for Redux, though. For situations like this, React recommends a different tool - its Context API, rebooted this year and no longer depreciated.
The Context API allows us to provide some data to all the descendants of a specific component that wish to consume it. Thanks to that we can avoid passing the data manually through numerous subtrees of our component hierarchy (like we did in the last example), which will lower the amount of noise but also make our data flow less explicit. In cases like the one in our example, it may be worth it.
Let’s try to rebuild it using the Context API.
Live demo: https://codesandbox.io/s/vxzvv1rol
const UserContext = React.createContext({
userId: null,
logIn: () => {},
logOut: () => {}
}) class App extends Component {
constructor () {
super()
this.state = {
userId: null
}
} logIn = () => {
this.setState({userId: 15})
} logOut = () => {
this.setState({userId: null})
} render () {
return (
<Router>
<UserContext.Provider value={{
userId: this.state.userId,
logIn: this.logIn,
logOut: this.logOut
}}>
<div className="App">
<Header/>
<Route exact path="/" component={LatestPostList}/>
<Route path="/hot" component={HotPostList}/>
<Route path="/top" component={TopPostList}/>
<Route path="/contact" component={Contact}/>
</div>
</UserContext.Provider>
</Router>
)
}
} const Header = () => (
<div className="Header">
<Logo/>
<HeaderMenu/>
</div>
) const Logo = () => <div className="Logo">?</div> const HeaderMenu = () => (
<UserContext.Consumer>
{({userId, logIn, logOut}) => (
<ul className="HeaderMenu">
<li><Link to="/">Latest</Link></li>
<li><Link to="/hot">Hot</Link></li>
<li><Link to="/top">Top</Link></li>
<li><Link to="/contact">Contact</Link></li>
<li>
{userId !== null ? (
<button onClick={logOut}>Log out</button>
) : (
<button onClick={logIn}>Log in</button>
)}
</li>
</ul>
)}
</UserContext.Consumer>
) class LatestPostList extends Component {
constructor () {
super()
// normally we would fetch latest posts from server
this.state = {
latestPosts: [{id: 185, content: 'Recent post 1'}, {id: 184, content: 'Recent post 2'}]
}
} render () {
return <PostList posts={this.state.latestPosts}/>
}
} class HotPostList extends Component {
// analogous to LatestPostList
} class TopPostList extends Component {
// analogous to LatestPostList
} const PostList = ({posts}) => (
posts.map(post => (
<Post key={post.id} content={post.content}/>
))
) const Post = ({content}) => (
<UserContext.Consumer>
{({userId}) => (
<div className="Post">
{userId === null ? content.substring(0, content.length \* 0.2) + '... (log in to see all)' : content}
</div>
)}
</UserContext.Consumer>
) const Contact = () => { let content = null return ( <UserContext.Consumer> {({userId}) => ( <form className="Contact" onSubmit={e => { e.preventDefault(); // fake message sending: console.log('Sending message: ' + content.value + ', from: ' + (userId !== null ? userId : 'guest')); }}> <textarea name="content" ref={input => content = input}/> <div> <button type="submit">Send</button> </div> </form> )} </UserContext.Consumer> ) }
As a first step, we create UserContext - an object that consists of Provider and Consumer components that we will use to pass data into the depths of the component tree, without threading it through all its levels. We use the UserContext.Provider component in App‘s render method - all the components wrapped in it will be able to consume the things passed to its value prop. Thanks to the Provider, App doesn’t have to manually pass userId to all of its children as it did before.
The HeaderMenu component, which previously accepted three props, received from App through Header, now uses UserContext.Consumer in its render method, getting the three dependencies from the Consumer, and freeing Header from accepting any props. Post does a similar thing - gets userId it depends on through UserContext.Consumer, thanks to which (Latest/Top/Hot)PostList components don’t have to thread it through themselves anymore. The last thing depending on userId - the Contact component - also reads it from UserContext for consistency.
By eliminating prop drilling of globally needed data, we have removed a lot of noise from the definitions of our components, at the cost of more implicit data passing code. In my opinion, in this case that was the way to go.
This wraps it up. I’d like you to remember three things after reading the two parts of my article:
- Prop-drilling is your friend - use it whenever you can until it becomes overwhelming; it makes your code easy to reason about.
- When it gets unruly, reach for the Inversion of Control principle and instantiate components higher in the tree where the data they need is available, passing them to the place where they need to be rendered.
- If that makes the top components too complex - for example, for data you need globally - use React Context.
- And don’t forget about Redux, it’s an awesome library. Just use it in situations it was designed for.
- If you have any questions, feel free to reach out to me in the comments section!