Avoiding boolean flags and impossible states when using declarative data fetching with React and Typescript
Tags: react, typescript, reactquery, remotedata
Published: 2022-10-26 18:39:15

In this post, I will try to convey some thoughts I've had when working with remote data and UI using React + TypeScript

[React Query](https://tanstack.com/query/v4) is a popular library for handling remote data in React applications. Its API is similar to other declarative data-fetching libraries in that it exposes a hook that takes a promise, and returns an object with properties that together make up the current state of the query.

Let's look at a basic example:

```ts
const User = () => {
  const { data } = useQuery<User, ApiError>('user', getUser)

  return (...)
}
```

The function `useQuery` has the following return type:

```ts
export interface QueryObserverBaseResult<TData = unknown, TError = unknown> {
  data: TData | undefined
  dataUpdatedAt: number
  error: TError | null
  errorUpdatedAt: number
  failureCount: number
  errorUpdateCount: number
  isError: boolean
  isFetched: boolean
  isFetchedAfterMount: boolean
  isFetching: boolean
  isLoading: boolean
  isLoadingError: boolean
  isInitialLoading: boolean
  isPaused: boolean
  isPlaceholderData: boolean
  isPreviousData: boolean
  isRefetchError: boolean
  isRefetching: boolean
  isStale: boolean
  isSuccess: boolean
  refetch: <TPageData>(
    options?: RefetchOptions & RefetchQueryFilters<TPageData>
  ) => Promise<QueryObserverResult<TData, TError>>
  remove: () => void
  status: QueryStatus
  fetchStatus: FetchStatus
}
```

Although this extensive object details the state of the query with great granularity, in theory, it also leaves the consumer with a very large number of possible states to represent.

Let's take a look at another example. A kind of bare minimum scenario for representing the current state of a query -- and a common pattern in React components.

What we want to do:

- Show the header at all times.
  &nbsp;
- If there is an error and we're not fetching, show the error.
  &nbsp;
- If we're loading, show a spinner.
  &nbsp;
- If we're fetching after an error, show a spinner
  &nbsp;
- If none of the above and there is data, show the data.

```ts
const User = () => {
  const {
    data, // TData | undefined,
    error, // TError | undefined,
    isLoading, // boolean,
    isFetching, // boolean
  } = useQuery<User, ApiError>('user', fetchUser)

  return (
    <div>
      <Header />
      // if error and not fetching, show error
      {error && !isFetching && <UserError error={error} />}
      // if loading or fetching and error, show spinner
      {isLoading || (isFetching && error) && <Spinner />}
      // if data and no error, show data
      {data && !error && <UserData user={data}>}
    </div>
  )
}
```

Here we're using four variables to represent our UI. These are four binary variables, which in theory give us 16 (4^2) possible states. This means there is a big chance we're not covering all of them\*.

> \*NOTE: In this case when using react-query, that's not necessarily true because some states may not be possible (e.g. `!data && isRefetching`). In my opinion, this is another good argument to try to represent states with sum-types instead. [Here's a good article on the subject if you're interested](https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/).

## Why this is a problem

Using this approach quite a lot, I've had a growing inconvenience with it, mainly because:

- We introduce a lot of boolean flags into components and thus adding exponential complexity to them.
  &nbsp;
- We introduce states with "varying possibility". As mentioned in the note above :point_up:, some combinations of states are impossible and nothing in the code successfully conveys that. Only careful inquiry into the documentation of the library will enlighten us about those details.
  &nbsp;
- We put a lot of responsibility into consumer-land without any type-inferred guidance on how to correctly approach it.
  &nbsp;
- We lose the concept of exhaustiveness. There is no type-inferred guidance as to how to correctly represent these possible states.
  &nbsp;
- We're not consolidating the API in relation to our application. The risk of doing something similar but different is non-trivial and may result in different behavior.

## An alternative approach

### Consolidate the API by transforming the query state into an opinionated representation of it by using discriminated unions

So we're going to transform `QueryObserverBaseResult<TData = unknown, TError = unknown>` into our own type `AsyncResult<TData, TError>`.

Our type will look like this:

```ts
type NotAsked = { status: 'notasked' }
type Loading = { status: 'loading' }
type Refreshing<T> = { status: 'refreshing'; data: T }
type Success<T> = { status: 'success'; data: T }
type Failure<TError, TData> = { status: 'failure'; error: TError; data?: TData }

type AsyncResult<TData, TError> =
  | NotAsked
  | Loading
  | Refreshing<TData>
  | Success<TData>
  | Failure<TError, TData>
```

The type `QueryObserverBaseResult<TData, TError>` is a lower-level representation of another type, `UseQueryResult<TData, TError>`, which is the return type of `useQuery`. We'll use that one instead.

So we need a function that maps `UseQueryResult<TData, TError>` into our own representation `AsyncResult<TData, TError>`. It will have the following signature:

```js
(queryResult: UseQueryResult<TData, TError>) => AsyncResult<TData, TError>
```

We'll name this function `createAsyncResult`. Here's the implementation:

```ts
export const createAsyncResult = <TData, TError>(
  queryResult: UseQueryResult<TData, TError>
): AsyncResult<TData, TError> | undefined => {
  if (queryResult.status === 'error' && !queryResult.isFetching) {
    // there is an error and we're not fetching
    return {
      status: 'failure',
      error: queryResult.error,
      data: queryResult.data,
    }
  }

  if (queryResult.fetchStatus === 'idle' && !queryResult.isFetched) {
    // query has not fired
    return { status: 'notasked' }
  }

  if (
    queryResult.status === 'loading' ||
    (queryResult.error && queryResult.isFetching)
  ) {
    // we're either initially loading or fetching after an error
    return { status: 'loading' }
  }

  if (queryResult.isFetching && queryResult.data) {
    // we're fetching and we have data
    return { status: 'refreshing', data: queryResult.data }
  }

  if (queryResult.status === 'success') {
    // we don't have an error, we're not initially loading and we have data
    return { status: 'success', data: queryResult.data }
  }
  // Returning undefined for now if we can't match the state.
  // I'd probably be more strict here and throw an error, but for
  // the purpose of this example it'll do.
  return undefined
}
```

Now we can use this function when fetching data:

```ts
const useUser = () => {
  const queryResult = useQuery<User, ApiError>(['user'], fetchUser)

  return createAsyncResult(queryResult)
}
```

And then in our component:

```ts
const User = () => {
  const asyncResult = useUser()

  const deriveComponent = () => {
    if (!asyncResult) {
      // if createAsyncResult failed to map the query result, return null
      return null
    }

    // using a switch here for exhaustiveness
    switch (asyncResult.status) {
      case 'notasked':
        return null
      case 'failure':
        return <UserError error={asyncResult.error} />
      case 'loading':
        return <Spinner />
      case 'success':
      case 'refreshing':
        return <UserData user={asyncResult.data} />
    }
  }

  const component = deriveComponent()

  return (
    <div>
      <Header />
      {component}
    </div>
  )
}
```

This is better but I think we can still improve the syntax, so we'll go ahead and install a library that can help with that:

```sh
npm install ts-pattern
```

[ts-pattern](https://github.com/gvergnaud/ts-pattern) is a great library for pattern matching in TS. We'll use it as follows:

```ts
const User = () => {
  const asyncResult = useUser()

  return (
    <div>
      <Header />
      {match(asyncResult)
        .with(undefined, () => null)
        .with({ status: 'notasked' }, () => null)
        .with({ status: 'failure' }, ({ error }) => <UserError error={error} />)
        .with({ status: 'loading' }, () => <Spinner />)
        .with({ status: 'success' }, { status: 'refreshing' }, ({ data }) => (
          <UserData user={data} />
        ))
        .exhaustive()}
    </div>
  )
}
```

We can disregard the first `.with(undefined, ...)` for now and take a look at our new type again:

```ts
type AsyncResult<TError, TData> =
  | NotAsked
  | Loading
  | Refreshing<TData>
  | Success<TData>
  | Failure<TError, TData>
```

and concerning our component :point_up::

- If status is `'notasked'`, it means the query has not been initialized, return null.
  &nbsp;
- If status is `'failure'`, it means state is of type `Failure<TError, TData>`, return the error.
  &nbsp;
- If status is `'loading'`, state is of type `Loading`, return the spinner.
  &nbsp;
- If status is `'success'` _or_ `'refreshing'`, our state is `Success<TData> | Refreshing<TData>`, we have data but might be refreshing, return `<UserData />`

Notice that in the first case we're matching `undefined` with `null`. This is because our `createAsyncResult` function might return `undefined`. If that happens it means the current state of the query is unrepresentable by the rules of our new state, so we return null.

> \*NOTE: Rather than returning undefined, another approach would be to throw an error. This way you wouldn't have to deal with the result might being undefined. I'm not yet confident this is a good idea though.. :P

Returning `null` was _implicitly_ happening before too. Out of all the combinations of states our query can have, we were only matching on some.

Let's revisit our second example:

```ts
const User = () => {
  const {
    data, // TData | undefined,
    error, // TError | undefined,
    isLoading, // boolean,
    isFetching, // boolean
  } = useQuery<User, ApiError>('user', fetchUser)

  return (
    <div>
      <Header />
      // if error and not fetching, show error
      {error && !isFetching && <UserError error={error} />}
      // if loading or fetching and error, show spinner
      {isLoading || (isFetching && error) && <Spinner />}
      // if data and no error, show data
      {data && !error && <UserData user={data}>}
    </div>
  )
}
```

We know from `UseQueryResult<TData, TError>` that we have a bunch of other properties that detail the state of our query. Above we're using some combination of `data`, `error`, `isLoading`, and `isRefetching` to decide what to render. If some other combinations of state happen to exist, we're not returning anything, i.e effectively `null`.

With our new implementation, on the first `.with(undefined, () => null)`, we're explicitly returning `null` for that same scenario.

## Conclusion

What did we do here? Let's summarize:

- States are now discriminated and this is something our types express.
  &nbsp;
- Leveraged types in a way that allows TS to give us reasonable guidance.
  &nbsp;
- We consolidated our data-fetching to one function that decides which states are allowed.
  &nbsp;
- Impossible states are gone\*.
  &nbsp;

  > \*NOTE: As long as we don't try to combine impossible states in our `createAsyncResult` function. But now you will only make that mistake at one place in your code base.

So far I've enjoyed working with this approach as it eliminates some of the vagueness these libraries introduce. There are most likely situations where this approach would need refactoring.

Here is a [sandbox with the code from this article](https://codesandbox.io/s/gallant-ellis-gegy2c?file=/src/async-result.ts)

Thanks to [lessp](https://github.com/lessp) for brainstorming and correcture.

Thanks for reading!