Javier Casas

A random walk through computer science

Constructing a Generic Data Loader Component with good typechecking in Typescript

Now that we know a few bits on how to use types to construct good React applications, it's time to apply them to practical problems. Nikica Novakovic kindly suggested an old problem that can be solved by using ADTs: abstracting the art of loading stuff to a component. Everything comes from an article praising ADTs in Elm: How Elm Slays a UI Antipattern. But, today, we will push for a Typescript implementation with guarantees comparable with the Elm one.

The old problem of asynchronously loading stuff

So we have a page that is intended to show some data that is loaded asynchronously. This means we have up to four possible UIs to show:

  • UI before fetching data from the server
  • UI while the data is being fetched from the server
  • UI to show an error in case the data fetch fails
  • UI to show the data after it has been fetched

These are a lot of UIs, possibly more than some people are willing to implement, so it's often the case that people take shortcuts while implementing this.

Suboptimal implementation 1: the default value that gets substituted later

The simplest approach to solve this problem is by showing a default value that will be substituted later.

class CommentsSection extends React.Component {
  constructor () {
    this.state = {
      data: []
    }
  }
  componentDidMount() {
    request(...).then(data => this.setState({ data }))
  }
  render () {
    return <div>
      <span>Comments: {this.state.data.length}</span>
      <ul>
        {this.state.data.map(comment => <li>{comment}</li>)}
      </ul>
    </div>
  }
}

For this comment list we are going to pretend that, before we have fetched the data from the server, we have zero comments. So, for a few moments, every thread of our forum is empty while the data is being loaded. If the loading fails, every thread has zero comments and we have no idea that something went wrong.

Suboptimal implementation 2: null makes a lousy data absence marker

Now we want to distinguish if we have loaded data or not, in order to show a different screen for lack of data and for having data.

class CommentsSection extends React.Component {
  constructor () {
    this.state = {
      data: null
    }
  }
  componentDidMount() {
    request(...).then(data => this.setState({ data }))
  }
  render () {
    return <div>
      <span>Comments: {this.state.data.length}</span>
      <ul>
        {this.state.data
          ? this.state.data.map(comment => <li>{comment}</li>)
          : <li>Loading comments...</li>}
      </ul>
    </div>
  }
}

With a tiny change we can show a loader while the data loads. This loader works as expected if the data is loaded correctly, and the data happens to not be null, or undefined, or any other falsy value. Also, any error will be shown as a never-ending "Loading comments..." comment. Better, but not good enough.

Applying ADTs

Following the Elm code, we have a basic definition using an ADT:

type RemoteData e a
    = NotAsked
    | Loading
    | Failure e
    | Success a

This ADT has four cases:

  • NotAsked for when we have not yet requested data
  • Loading for when we have requested the data, but it has not arrived yet
  • Failure e for when the request failed with error e
  • Success a for when the request succeeded with data a

Let's convert this to Typescript:

type RemoteData<E, A> = {
    type: "NotAsked";
  } | {
    type: "Loading";
  } | {
    type: "Failure";
    error: E;
  } | {
    type: "Success";
    value: A;
  }

The same way as the Elm type, this type has two type parameters:

  • E for an error type
  • A for a result type

And we have the same four cases, using the type field as disambiguator. Let's work on the CommentsSection component:

interface Props {}

interface State {
  data: RemoteData<string, Comment[]>;
}

class CommentsSection extends React.Component<Props, State> {
  constructor () {
    this.state = {
      data: { type: "NotAsked" }
    }
  }
  componentDidMount() {
    this.setState({data: { type: "Loading" }})
    request(...)
      .then(value => this.setState({data: { type: "Success", value }}))
      .catch(error => this.setState({data: { type: "Failure", error }}))
  }
  render () {
    const data = this.state.data;
    if (data.type === "NotAsked") {
      return (
        <div>
          Comments not being loaded
        </div>
      )
    } else if (data.type === "Loading") {
      return <div>Loading...</div>
    } else if (data.type === "Failure") {
      return <div>Failure fetching data: {data.error}</div>
    } else if (data.type === "Success") {
      return <div>
        <span>Comments: {data.value.length}</span>
        <ul>
          {data.value.map(comment => <li>{comment}</li>)}
        </ul>
      </div>
    }
  }
}

This is significantly better, as it can finally manage all the cases, and we can have some types checking what we do. We don't have any of the problems of the previous approaches, we can load anything from the server, including falsy stuff, and everything is great. But, this is a pattern we are bound to repeat all over the place in our code. So, can we abstract it out?

Generic data loader with renderprops for the win!

Indeed we can abstract this out and get something that we can reuse all over the place in our code. In order to abstract it out, we need to figure out what bits may change depending on the context:

  • The Promise that fetches the data will change depending on what data do we have to fetch.
  • The UIs will change in order to show different data in different locations in different ways.

In order to implement the second, we need to use something that allows us to pass different components and get them rendered properly. We could use Higher-Order Components here, but they are not that nice to use. Instead, we will use renderprops.

The four interfaces

We have four different interfaces to show, one for each of the four alternatives of the RemoteData<E, A> ADT:

  • { type: "NotAsked" } for when we have not yet requested data
  • { type: "Loading" } for when we have requested the data, but it has not arrived yet
  • { type: "Failure", error: E } for when the request failed with error E
  • { type: "Success", value: A } for when the request succeeded with data A

We need an interface for each one, and we can use some basic type theory to nail the types of all of them. The first two are constant values, because they don't have any kind of parameter or any kind of value we can vary outside the "NotAsked" and "Loading" constant strings. Which means their UIs can be as simple as functions with no parameters:

type NotAskedUI = () => JSX.Element;
type LoadingUI = () => JSX.Element;

We can even think that these functions will be just generating always the same UIs. After all, if this is about pure functions and functional programming, we should expect that these functions will always return the same values. So:

type NotAskedUI = JSX.Element;
type LoadingUI = JSX.Element;

But, for the other two alternatives, we have something that can vary, and this means we need it to behave as parameters:

type FailureUI<E> = (error: E) => JSX.Element;
type SuccessUI<A> = (value: A) => JSX.Element;

The data fetcher

For fetching the data, we need a type that can generate promises on demand, but with values parametrised by our E and A parameters:

type DataFetcher<E, A> = () => Promise<A>

Unfortunately, Promises can be failed with Promise.reject or with a throw. The throw is a problem here, because it's effectively a bottom type (the type of computations that never complete successfully), and thus can become any effective type. This means we can't really predict the error type, so we'll have to accept it to be an any.

The type for props and state

We have enough to define the type of the props for our component:

interface Props<E, A> {
  notAskedUI: NotAskedUI;
  loadingUI: LoadingUI;
  failureUI: FailureUI<E>;
  successUI: SuccessUI<A>;
  dataFetcher: DataFetcher<E, A>;
}

We can also make the type of the state for our component:

interface State<E, A> {
  data: RemoteData<E, A>;
}

The component

We now can construct our component with the props and state we have:

class RemoteData<E, A> extends React.Component<Props<E, A>, State<E, A>> {
  constructor () {
    this.state = {
      data: { type: "NotAsked" }
    }
  }
  componentDidMount() {
    this.setState({data: { type: "Loading" }})
    this.props.dataFetcher()
      .then(value => this.setState({data: { type: "Success", value }}))
      .catch(error => this.setState({data: { type: "Failure", error }}))
  }
  render () {
    const data = this.state.data;
    if (data.type === "NotAsked") {
      return this.props.notAskedUI
    } else if (data.type === "Loading") {
      return this.props.loadingUI
    } else if (data.type === "Failure") {
      return this.props.failureUI(data.error)
    } else if (data.type === "Success") {
      return this.props.successUI(data.value)
    }
  }
}

See that we propagate the E and A types to the Props and State types we designed before, and this allows us to construct a generic component. Using it is quite simple, and shows the level of abstraction we achieved:

const CommentsSection = () => 
  <RemoteData
    dataFetcher={() => request(...)}
    notAskedUI={<div>No data has been fetched yet</div>}
    loadingUI={<div>Loading comments...</div>}
    failureUI={(error) => <div>An error happened loading data: {error}</div>}
    successUI={(comments) => <ul>{ comments.map(comment => <li>{comment}</li>) }</ul>}
    />

The full code is at the remote-data Github repository, so feel free to grab it and have a look!

Back to index
Copyright © 2018-2023 Javier Casas - All rights reserved.