Render Prop Pragmatism
Render Props are a React pattern for component composition that can solve essentially the same challenges as higher-order components. While higher-order components are based on higher-order functions, which wrap other functions and can modify arguments, render props are based on callback functions. In many ways, render props are an inversion of the higher-order component pattern, flipping it inside out to inject props inside your component rather than outside. This simplifies many use cases, but it's also easy to misuse - resulting in a tangled pyramid of callback functions that can be difficult to maintain.
When Props Collide
First, though - let's talk about the advantages render props have over higher-order components. The biggest, in my mind, is their more explicit nature versus the implicit pass-through nature of higher-order components. This is especially evident if you're using a strict type system like TypeScript. Consider the overly-simplified component below:
import React, {Component} from 'react'
import {TouchableOpacity} from 'react-native'
import {connect} from 'react-redux'
interface Item {
id: string
}
interface Props {
items: Item[]
isAuthenticated: boolean
}
const mapState = state => ({isAuthenticated: state.user.isAuthenticated})
export class Collection extends Component<Props> {
componentDidMount () {
const {isAuthenticated} = this.props
if (!isAuthenticated) {
console.log('Client-side redirect: engage!')
}
}
render () {
const {isAuthenticated, items} = this.props
if (isAuthenticated) {
return items.map(this.renderItem)
} else {
return null
}
}
private renderItem = (item: Item) => {
return (
<TouchableOpacity onPress={this.handlePress(item)}>
{item}
</TouchableOpacity>
)
}
private handlePress = (item: Item) => () => {
console.log('Client-side route: engage!', item)
}
}
export default connect(mapState)(Collection)
If I want to bring in react-navigation
, I need to use the withNavigation
higher-order component:
import {NavigationInjectedProps, withNavigation} from 'react-navigation'
interface Props extends NavigationInjectedProps {
items: Item[]
isAuthenticated: boolean
}
// ...
componentDidMount () {
const {isAuthenticated, navigation} = this.props
if (!isAuthenticated) {
navigation.navigate('Login')
}
}
// ...
private handlePress = (item: Item) => () => {
this.props.navigation.navigate('Item', {item})
}
// ...
This makes my Props
interface implicitly grow with whatever is in NavigationInjectedProps
, and TypeScript must work to ensure that each interface is compatible with each other. When your component is simple like this one, not much of a challenge is presented. Other wrappers are more complex, however. The higher-order components included with react-router
and formik
both inject a variety of props. The react-apollo
higher-order component injects a single prop that is very complex and relies on a generic type argument.
All of these wrapper components and their props must be merged not just into a single often monolithic Props
interface to be used with your inner component, but also each intermediate step along the way must be checked by the type checker for compatibility. When the type checker can’t unify types on an intermediate step, the error message is often obnoxiously inscrutable:
Scope Restraint
Render props sidestep this problem by not attempting to intersect the props at all, rather providing a constrained set of local props to a callback function. Consider a slightly different example:
import React, {Component} from 'react'
import {Collection as CollectionType} from '../../../data/CollectionData'
import CollectionQuery from '../../data/CollectionQuery'
interface Props {}
export default class Screen extends Component<Props> {
render () {
return (
<CollectionQuery render={this.renderWithCollections} />
)
}
private renderWithCollections = (collections: CollectionType[]) => {
return (
<>
{/* ... */}
</>
)
}
}
With this approach, rather than injecting the retrieved collections
as an outer prop the user receives the prop as a callback, keeping the scope local to the function. This allows your props to more effectively reflect what consumers of the component need to provide in order to use it, and it makes the type checking much easier and more obvious.
How the Render Prop is Made
Here’s how the CollectionQuery
component used above is put together:
import React, {Component, ReactNode} from 'react'
import {Query, QueryResult} from 'react-apollo'
import {AllCollections, AllCollectionsData, Collection} from '../../data/CollectionData'
interface Props {
render (collections: Collection[]): ReactNode
}
export default class CollectionQuery extends Component<Props> {
render () {
return (
<Query<AllCollectionsData> query={AllCollections}>
{this.renderWithResults}
</Query>
)
}
private renderWithResults = (results: QueryResult<AllCollectionsData>) => {
if (results.loading || !results.data) {
return null
}
const {render} = this.props
const {data: {allCollections: {nodes: collections}}} = results
return render(collections)
}
}
The render (
collections
: Collection[]): ReactNode
expression defines a render prop that takes a callback expecting the retrieved collections, and returning something renderable.
One unique feature of render props, however, is that you don’t have to rely on a single children
prop to define a renderable region in your component. You can use multiple render props named whatever you like, giving your consumer flexibility to render a variety of things in a variety of places within your component.
Unicorns & Rainbows Forever!
Hooray! We’re saved! Right? Well, not quite. With the advantages of callback functions come the pitfalls, and you can easily find yourself tangled up in a pyramid of nested inline functions:
render () {
return (
<Query<AllCollectionsData> query={AllCollections}>
{(results: QueryResult<AllCollectionsData>) => {
if (results.loading || !results.data) {
return null
}
const {data: {allCollections: {nodes: collections}}} = results
return (
<Query<OtherQueryData> query={OtherQuery}>
{(otherResults: QueryResult<OtherData>) => {
if (otherResults.loading || !otherResults.data) {
return null
}
const {data: {allCollections: {nodes: others}}} = otherResults
return (
<Formik
render={(formik: FormikProps<Values>) => {
return (
<>
<form>
{/* ... */}
</form>
<TouchableOpacity onPress={() => doSomething(collections, others)}>
<Text>Do Something</Text>
</TouchableOpacity>
</>
)
}}
/>
)
}}
</Query>
)
}}
</Query>
)
}
To fight this, I embrace a few strategies to keep things under control:
- Use class methods to logically organize callbacks
- Break components up into small, single-purpose pieces wherever practical
- Abstract repeated structures into reusable pieces
Ethical Class-Based Components
Classes are often regarded with suspicion in JavaScript because they are a rather indirect abstraction – they hide how prototype inheritance in JavaScript truly works in a way that can cause unexpected and confusing behavior.
If you avoid implicit state and keep things practically pure, class methods can be a clean and obvious way to organize callback functions for things like render props and event handlers.
import { QueryResult } from "react-apollo";
import { TouchableOpacity } from "react-native-gesture-handler";
export default class MyComponent extends Component {
render () {
return (
<Query<MyData> query={MyQuery}>
{this.renderWithData}
</Query>
)
}
private renderWithData = (results: QueryResult<MyData>) => {
if (results.loading || !results.data) {
return null
}
const {render} = this.props
const {data: {myData: {nodes: myData}}} = results
return myData.map(this.renderData)
}
private renderData = (data: Data) => {
return (
<TouchableOpacity key={`data:${data.id}`} onPress={this.handlePress(data.id)}>
{data.title}
</TouchableOpacity>
)
}
private handlePress = (id: string) => () => {
console.log('Tada!')
}
}
The event handler takes the piece of data from the Query
and passes it into a curried event handler. Since the function isn’t defined inside the render function, it’s not re-created on each render and the JavaScript engine can build up a more effective optimization profile. This is a micro-optimization, however. More importantly, your code is clean and easy to read without increasing levels of nesting.
This is only usable in small bites, however. If you pack a single component with a dozen render functions and just as many event handlers, you’ll find yourself or your team scratching your heads trying to pick apart all the things that are happening in one place. Breaking components into smaller pieces helps with readability and maintainability.
Abstracting reusable routines into render-prop components as I did above with ComponentQuery
helps a lot to reduce boilerplate and allow you to take advantage of your effort in more places, without dealing with growing Props
types and convoluted type errors.
Inversion of Control
Another important aspect of render props is the way that they allow for an inversion of control from the callee to the caller. Rather than giving a component an intricate set of configuration props to describe all of the ways that you anticipate the user would want the component to be rendered, you can give the user the power to render whatever they want using the callback function.
Say you have a CoolWidget
that has a left
and a right
region, and you want to allow the user to render their own icon in each spot.
export default class MyComponent extends Component {
render () {
return (
<CoolWidget
left={{icon: 'left-arrow'}}
right={{icon: 'right-arrow'}}
/>
)
}
}
What if the user wants to render their own icon that you don’t support in your library? Or, what if your user wants to render something else entirely? You can either provide an ever-growing set of props to describe an ever-growing set of internal functionality based on them, or you can use callback functions to render whatever the caller gives you.
export default class MyComponent extends Component {
render () {
return (
<CoolWidget
left={this.renderLeft}
right={this.renderRight}
/>
)
}
}
It doesn’t matter what this.renderLeft
and this.renderRight
are in this case – because they could be anything! As long as you constrain your left
and right
props to return ReactNode
, then you know you’ll be able to render it in the right spot.
The Right Tool for the Job
Render props aren’t a cure-all for tangled higher-order components, and they come with their own whole new set of pitfalls. At their best, however, they can flip the wrapper-component pattern inside-out, making it easier to keep track of props without having to check interlocking sets of intermediate props along a complicated pipeline of wrapping components.
Sometimes, however, higher-order components really are the best tool for the job. Redux’s connect
function remains an outstanding way to attach global state to your components in a controlled way. The withApollo
wrapper is still great for quickly injecting a GraphQL client that you can anywhere inside your component. The more complex your component composition chain is, however, the more opportunities you encounter for confusing prop issues.
As in anything, make sure you’re taking a balanced approach and considering whether you’re using the right tool for the problem you’re solving. With some care, render props can be a well-controlled way to compose components in an obvious way, while providing valuable inversion of control and allowing for easier type checking!