altay-dot-wtf

Dear state, please stop lying

updated 9 months ago
ยท
5 min 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

Resources

๐Ÿ™‡โ€โ™‚๏ธ

A huge favor

Please let me know if anything you read here was confusing, incorrect, or outdated. Just write a few words, and I will be grateful to you for the rest of my life.
altay@aydemir.io
ยท
@altayaydemir