Back to all articles

Refactoring React with Render Props

Intro

If you can stand 30 minutes of improvised live coding go ahead and watch the video. Otherwise, down below you can find a summary.

Written Summary

Recently, I had a lot of fun working with some Lunar folks: Alex, Ika and Shadi. In particular, we learnt React together by adding some frontend magic to the project they developed during the internship.

The domain of the application is not relevant for this article. You can think of it as a blog. In particular, we wrote some code to fetch posts from an API, paginate and then render them. We ended up with something similar to this:

const API_URL = "https://jsonplaceholder.typicode.com/posts"
const PER_PAGE = 10
const STATUS = {
  notAsked: "NOT_ASKED",
  pending: "PENDING",
  resolved: "RESOLVED",
  failed: "FAILED",
}

export class PostsContainer extends Component {
  state = {
    page: Paginator.firstPage([], PER_PAGE),
    posts: [],
    status: STATUS.notAsked,
  }

  componentDidMount = () =>
    this.setState({status: STATUS.pending}, () => {
      this
      .fetchPosts()
      .catch(() => this.setState({status: STATUS.failed}))
    })

    fetchPosts = () =>
      fetch(API_URL)
      .then(postsData => postsData.json())
      .then(posts => this.setState({posts, status: STATUS.resolved}))

  firstPage = () => Paginator.firstPage(this.state.posts, PER_PAGE)
  lastPage = () => Paginator.lastPage(this.state.posts, PER_PAGE)

  nextPage = () => Paginator.nextPage(this.state.posts, PER_PAGE, this.state.page)
  prevPage = () => Paginator.prevPage(this.state.posts, PER_PAGE, this.state.page)

  prevPageExists = () => Paginator.hasPrevPage(this.state.posts, PER_PAGE, this.state.page)
  nextPageExists = () => Paginator.hasNextPage(this.state.posts, PER_PAGE, this.state.page)

  onFirstPage = () => this.state.page === this.firstPage()
  onLastPage = () => this.state.page === this.lastPage()

  currPagePosts = () => Paginator.getPageItems(this.state.posts, PER_PAGE, this.state.page)

  render = () => {
    switch(this.state.status) {
    case STATUS.notAsked:
    case STATUS.pending:
      return <Spinner />
    case STATUS.resolved:
      { return this.state.posts.length > 0 ?
        <PostsTable
          posts={this.currPagePosts()}
          next={() => this.setState({page: this.nextPage()})}
          prev={() => this.setState({page: this.prevPage()})}
          first={() => this.setState({page: this.firstPage()})}
          last={() => this.setState({page: this.lastPage()})}
          prevDisabled={!this.prevPageExists()}
          nextDisabled={!this.nextPageExists()}
          firstDisabled={this.onFirstPage()}
          lastDisabled={this.onLastPage()}
          currPage={this.state.page}
          lastPage={this.lastPage()}
        /> : "Posts list is empty" }
    case STATUS.failed:
      return <div className="text-center">Something went wrong! :(</div>
    default:
      throw new Error(`case ${this.state.status} not valid`)
    }
  }
}

The code works well but needs some refactoring. In particular, we noticed that the component is taking care of three things:

  • fetching
  • paginating
  • rendering (partly delegated to PostsTable)

This breaks the single responsibility principle. That becomes clear when trying to reuse PostsContainer in other contexts. For example, fetching posts without pagination, rendering in a different way or handling other resources than posts. With this structure that isn’t possible. At least, not without introducing duplication by copy / pasting.

After some refactoring we’ve come up with the following code, which you can play with in the sandbox. Notice that we’ve slightly simplified the initial problem by removing some noise. At the same time we’ve kept the same complexity (i.e. fetching, paginating, rendering).

class App extends Component {
  render() {
    const render_ = array => array.map(x => x.id).join(", ")
    return (
      <RemoteData
        url="https://jsonplaceholder.typicode.com/posts"
        notAsked={() => <div>"Spinner"</div>}
        pending={() => <div>"Spinner"</div>}
        failure={() => <div>"Failure"</div>}
        success={(posts) =>
          <Paginated
            array={posts}
            render={posts => render_(posts)}
          />
        }
      />
    )
  }
}
class RemoteData extends Component {
  state = {
    data: [],
    status: "NotAsked",
  }

  componentDidMount = () => {
    const fetchData = () =>
      fetch(this.props.url)
      .then(response => response.json())
      .then(data => this.setState({ ...this.state, data, status: "Success" }))
      .catch(() => this.setState({ ...this.state, status: "Failure" }))

    this.setState({ ...this.state, status: "Pending" }, fetchData)
  }

  render = () => {
    if (this.state.status === "NotAsked") {
      return this.props.notAsked()
    } else if (this.state.status === "Pending") {
      return this.props.pending()
    } else if (this.state.status === "Success") {
      return this.props.success(this.state.data)
    } else if (this.state.status === "Failure") {
      return this.props.failure()
    } else {
      throw new Error("not valid")
    }
  }
}
class Paginated extends Component {
  state = {
    page: 0,
    perPage: 2,
  }

  goTo = (offset) => {
    const newPage = this.state.page + offset
    this.setState({ ...this.state, page: newPage })
  }

  render = () => {
    const page = this.state.page
    const perPage = this.state.perPage
    const toShow = this.props.array.slice(page * perPage, page * perPage + perPage)
    return (<div>
      <div>{this.props.render(toShow)}</div>
      <button onClick={() => this.goTo(-1)}>-</button>
      <button onClick={() => this.goTo(+1)}>+</button>
    </div>)
  }
}

As you can see, RemoteData and Paginated are passed some functions to take care of the rendering. Those are known in the React community as Render Props. The beauty of it is that the caller is in charge of deciding how to render stuff. Fetching and pagination are taken care by two other components.

Separating responsibilities enables code reuse. We mentioned earlier a few examples we couldn’t handle with the first version of the code. Let’s see what’s up now. Remember that you can copy / paste the examples into the sandbox and play with the code.

Fetching posts without pagination

Just drop the use of Paginated.

class App extends Component {
  render() {
    const render_ = array => array.map(x => x.id).join(", ")
    return (
    <RemoteData
        url="https://jsonplaceholder.typicode.com/posts"
        notAsked={() => <div>"Spinner"</div>}
        pending={() => <div>"Spinner"</div>}
        failure={() => <div>"Failure"</div>}
        success={(posts) => render_(posts)}
      />
    )
  }
}

Rendering in a different way

Let’s say we wanted to render the title instead of the id for each post. Changing render_ is the only thing needed.

class App extends Component {
  render() {
    const render_ = array => array.map(x => x.title).join(", ")
    return (
      <RemoteData
        url="https://jsonplaceholder.typicode.com/posts"
        notAsked={() => <div>"Spinner"</div>}
        pending={() => <div>"Spinner"</div>}
        failure={() => <div>"Failure"</div>}
        success={(posts) => render_(posts)}
      />
    )
  }
}

Handle other things than posts

For example let’s fetch some users (instead of posts). It’s enough to change the url in RemoteData and the render_ function.

class App extends Component {
  render() {
    const render_ = array => array.map(x => x.name).join(", ")
    return (
      <RemoteData
        url="https://jsonplaceholder.typicode.com/users"
        notAsked={() => <div>"Spinner"</div>}
        pending={() => <div>"Spinner"</div>}
        failure={() => <div>"Failure"</div>}
        success={(users) => render_(users)}
      />
    )
  }
}

Outro

The video covers the refactoring from original to final version of the code in much more detail. If you are interested into the thinking behind the refactoring and the step-by-step evolution of the code, then go ahead and watch it!

Share this article: