Writing

Dear state, please stop lying

February 22, 2020ยท4 mins read

Is managing the UI state is hard, or we make it hard for ourselves by not paying attention?

How do we do that? ๐Ÿค”

We have this React component fetches the list of something and renders accordingly.

class List extends React.Component {
  state = { loading: false, data: [], error: null }

  async componentDidMount() {
    this.setState({ loading: true })

    try {
      const { data } = await api.getData()
      this.setState({ loading: false, data })
    } catch (error) {
      this.setState({ loading: false, error })
    }
  }

  render() {
    if (this.state.loading) {
      return <p>Loading...</p>
    }

    if (this.state.error) {
      return (
        <p>
          Sorry, you broke the app. <br />
          Here's what our server says: {this.state.error.message}
        </p>
      )
    }

    return (
      <ul>
        {this.state.data.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    )
  }
}

Pretty straightforward, huh?

Probably not ๐Ÿ˜ฌ

Our state initializes with loading=false and fetchData is called on componentDidMount which means, right after the initial render.

This will cause a flash in the Loading text since it's displayed after we render the empty list in our initial state (data=[])

But programmers are pragmatic and smart people; we can fix this by initializing the state with loading=true?

โœ… - No more flashing UI.

๐Ÿ˜ฐ - We say that something is loading but that is not the reality, nothing is literally loading until fetchData is called.

Our state is lying, but we can live with that.

Request failed? Oh, snap!

Let's take one more step forward for a better UX and add a retry button to the error component.

class List extends React.Component {
  state = { loading: true, data: [], error: null }

  componentDidMount() {
    this.fetchData()
  }

  fetchData = async () => {
    this.setState({ loading: true, error: null }) // WHY ๐Ÿคก

    try {
      const { data } = await api.getData()
      this.setState({ loading: false, data })
    } catch (error) {
      this.setState({ loading: false, error })
    }
  }

  render() {
    if (this.state.loading) {
      return <p>Loading...</p>
    }

    if (this.state.error) {
      return (
        <div>
          <p>
            Sorry, you broke the app. <br />
            Here's what our server says: {this.state.error.message}
          </p>

          <button type="button" onClick={this.fetchData}>
            Try again
          </button>
        </div>
      )
    }

    return (
      <ul>
        {this.state.data.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    )
  }
}

OK, this would work for our beloved users.

But readability is not optimal because we're doing some nasty things. We need to perform a clean-up since error might be a leftover from the first call.

Again, we may come up with an ingenious solution to make that bearable by introducing a custom onRetry method.

class List extends React.Component {
  // ...

  fetchData = async () => {
    // this.setState({ loading: true, error: null }) Gone ๐Ÿ‘‹

    try {
      const { data } = await api.getData()
      this.setState({ loading: false, data })
    } catch (error) {
      this.setState({ loading: false, error })
    }
  }

  onRetry = () => {
    this.setState(
      {
        loading: true,
        error: null,
      },
      () => this.fetchData(),
    )
  }

  // ...
}

โœ… - More readable and traceable.

๐Ÿ˜ฐ - We are skipping a fundamentally crucial point, and our state is still lying.

Time to question our choices

  • Why can our state have an error while loading is true?
  • Why does our state have data=[] while loading is true?
  • Why is that possible to have data and error at the same time?

๐Ÿ‘จ๐Ÿปโ€๐Ÿ’ป: Dear state, pls stop lying.

๐Ÿค–: 01001000011001010110110001110000

How types can help us to make better choices

It's time to enter the safe and shiny world of types, where we can stop making impossible state possible.

๐Ÿฅ๐Ÿฅ๐Ÿฅ

Let's start with a tiny touch and strip the meaningless properties from different types of UI states.

type LoadingState = { type: 'loading' }
type SuccessState<T> = { type: 'sucess'; data: T }
type FailureState = { type: 'failure'; error: Error }

Now we can use the simple but powerful discriminated (or tagged) unions feature of TypeScript.

type State<T> = LoadingState | SuccessState<T> | FailureState

By revisiting our state design, we made all these questions irrelevant.

  • Why can our state have an error while loading is true?
  • Why does our state have data=[] while loading is true?
  • Why is that possible to have data and error at the same time?

How would that affect our component?

class List extends React.Component {
  // no more pointless initial values
  state: State<{ id: number; name: string }[]> = { type: 'loading' }

  componentDidMount() {
    this.fetchData()
  }

  async fetchData() {
    try {
      const { data } = await api.getData()
      this.setState({ type: 'success', data })
    } catch (error) {
      this.setState({ type: 'failure', error })
    }
  }

  // no more nasty clean-ups
  onRetry = () => this.setState({ type: 'loading' }, this.fetchData)

  // no more top level if statements
  render() {
    switch (this.state.type) {
      case 'loading':
        return <p>Loading...</p>

      case 'failure':
        return (
          <div>
            <p>
              Sorry, you broke the app. <br />
              Here's what our server says: {this.state.error.message}
            </p>

            <button type="button" onClick={this.onRetry}>
              Try again
            </button>
          </div>
        )

      case 'success':
        return (
          <ul>
            {this.state.data.map((item) => (
              <li key={item.id}>{item.name}</li>
            ))}
          </ul>
        )
    }
  }
}

Looks better?

There are more benefits than making the state a trustworthy citizen.

  • We have proper types for the data.
  • We can have proper types for the error by creating custom exception types (will discuss later).
  • TypeScript will also help us to catch programmer errors to prevent runtime exceptions, as shown below.
switch (this.state.type) {
  case 'loading':
    return <p>Loading: {this.state.data[0].id}</p> // TSC SAYS NO!

๐Ÿค–: 01001110011010010110001101100101

References