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!