Brandon Konkle
Brandon Konkle

Software Architect at Community Funded, GraphQL/Node/React developer, TypeScript nerd, Linux enthusiast, supporter of social justice, loving husband & dad.

I'm a Node & React developer with more than a decade of experience creating high performance web applications and architectures. If you're looking for help with your next project, hire me today!

Share


Tags


avrt
Brandon Konkle

Render Prop Pragmatism

Brandon KonkleBrandon Konkle

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:

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!

I'm a Node & React developer with more than a decade of experience creating high performance web applications and architectures. If you're looking for help with your next project, hire me today!

View Comments