A React HOC for resolving promises
2018-03-21React Higher-Order Components is a great technique and pattern that emerges from React’s compositional nature. If you want to abstract certain commonly used logic like logging and data fetching you might want to consider to move that code into an HOC. One thing that can result in quite a lot of repetetive boilerplate code is promise resolving if you use Fetch API or any other API that uses promises. Lets look at an example that uses GitHub API to fetch a quote. First we have a component that simply render a quote and can reload with a new one:
import React from 'react'
import T from 'prop-types'
const Quote = props =>
<div>
<p>
{props.firstName} says:
</p>
<q>
{props.theQuote}
</q>
<button
onClick={props.reload}>
Load again
</button>
</div>
Quote.propTypes = {
theQuote: T.string.isRequired,
firstName: T.string.isRequired,
reload: T.func.isRequired
}
export default Quote
...and one simple file that contains the fetch logic
const handleError = r => {
if (!r.ok) throw Error(r.statusText) //handle http errors
return r
}
const quoteApi = url =>
fetch(url)
.then(handleError)
.then(r => r.text())
export default quoteApi
Typically you have a React class that handles the state:
import React, { Component } from 'react'
import Quote from './Quote'
import { quoteApi } from './quoteApi'
class QuoteAjax extends Component {
state = {
loading: true,
data: null,
error: null,
}
componentDidMount() {
this.fetchQuote(false)
}
componentWillReceiveProps() {
this.fetchQuote()
}
fetchQuote = (load = true) => {
if (load) {
this.setState({ data: null, loading: true })
}
quoteApi('https://api.github.com/zen')
.then(text => this.setState({ data: text, loading: false }))
.catch(error => this.setState({ error: error, loading: false }))
}
render() {
const { data, loading, error } = this.state
if (loading === true)
return <span>loading...</span>
if (error !== null)
return <span>Error: {error.message}</span>
return (
<Quote
theQuote={data}
firstName={this.props.firstName}
reload={this.fetchQuote} />
)
}
}
export default QuoteAjax
// assuming the call is like: <QuoteAjax firstName="Bob" />
There is some repetetive code here like error handling, state variables and displaying a loading spinner. If you move this code to an HOC the same implementation might look like this:
import Quote from './Quote'
import { quoteApi } from './quoteApi'
import promisedHOC from './PromisedHOC'
const promise = () =>
quoteApi('https://api.github.com/zen')
.then(text => ({ theQuote: text })) // returns object used in component
export default promisedHOC(promise)(Quote)
We saved quite a few LOC and likely reduce the risk of bugs when using this across a codebase. The HOC for this would look like this:
import React, { Component } from 'react'
const promisedHOC = (promise, mapStateToProps) =>
(Wrapped, Loader, Failed) =>
class extends Component {
//displayName in developer tools
static displayName = `promisedHOC(${(Wrapped.displayName || Wrapped.name || 'Component')})`
state = {
loading: true,
data: null,
error: null
}
componentDidMount() {
this.execPromise(this.props, false)
}
componentWillReceiveProps(nextProps) {
// you probably want this to be configurable
if (this.props !== nextProps) {
this.execPromise(nextProps)
}
}
execPromise = async (props, load = true) => {
if (load) {
this.setState({ data: null, loading: true })
}
let res = null
try {
res = await promise(props)
}
catch (error) {
this.setState({ error: error, loading: false })
return
}
if (mapStateToProps !== undefined) {
res = mapStateToProps(res)
}
this.setState({ data: res, loading: false })
}
//execute promise again
reload = () => this.execPromise(this.props)
render() {
const { data, loading, error } = this.state
if (loading === true) {
if (Loader !== undefined){
return <Loader />
}
return <span>Loading..</span>
}
if (error !== null) {
console.error(error)
if (Failed !== undefined){
return <Failed message={error.message} />
}
return <span>Error occured</span>
}
// ... and renders the wrapped component with the fresh data!
// Notice that we pass through any additional props
// the "data" variable is spread to be accessible in the component
return <Wrapped {...data} {...this.props} reload={this.reload} />
}
}
export default promisedHOC
The "Quote" component would then have the property "reload", the "data" object is "spread" to properties ("theQuote"), it would also contain any props that were passed through ("firstName"). This HOC example also have some additional stuff that can be done:
// we have access to the props sent
const promise = ownProps => quoteApi(ownProps.gitUrl)
// might be more convenient to map the state to props in a function
const mapToProps = state => ({ theQuote: state })
//possible to inject components to override default rendering
const Loader = () => <i className="fa fa-spinner fa-pulse fa-3x fa-fw" />
const Fail = props => <span>Error: {props.message}</span>
const TheQuote = promisedHOC(promise, mapToProps)(Quote, Loader, Fail)
//usage
<TheQuote
firstName="Danne"
gitUrl="https://api.github.com/zen" />
One question is what to do when you want to resolve multiple promises for one component, you could certainly modify the HOC above for that purpose, another approach is to use composition and/or injection, example below shows how to inject one component into another:
const Quote = props => {
const { AnotherQuote } = props
return {
<div>
<p>{props.firstName} says: </p>
<q>
{props.theQuote}
</q>
<AnotherQuote />
</div>
}
}
Quote.propTypes = {
AnotherQuote: T.func.isRequired
}
const QuoteSimple = props =>
<q>
{props.theQuote}
</q>
const promise = () =>
quoteApi('https://api.github.com/zen')
.then(text => ({ theQuote: text }))
const TheQuote = promisedHOC(promise)(Quote)
const AnotherQuote = promisedHOC(promise)(QuoteSimple)
const QuotePage = () =>
<TheQuote
firstName="Danne"
AnotherQuote={AnotherQuote} />
export default QuotePage
// note: this would mean the api request's are done sequential,
// i.e "AnotherQuote" will wait for the first to render, which might not be desired
// Example below would do parallel requests:
const TheQuote = promisedHOC(promise)(QuoteSimple)
const AnotherQuote = promisedHOC(promise)(QuoteSimple)
const QuotePage = () => {
return {
<div>
<p>{props.firstName} says: </p>
<TheQuote />
<AnotherQuote />
</div>
}
}
//injection could also be achived with "children" props:
<TheQuote
firstName="Danne">
<AnotherQuote />
</TheQuote>