Avoiding boolean flags and impossible states when using declarative data fetching with React and Typescript

momentiris

Andreas Lundqvist

Posted on October 26, 2022

Avoiding boolean flags and impossible states when using declarative data fetching with React and Typescript

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 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:

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

  return (...)
}
Enter fullscreen mode Exit fullscreen mode

The function useQuery has the following return type:

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
}
Enter fullscreen mode Exit fullscreen mode

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.  
  • If there is an error and we're not fetching, show the error.  
  • If we're loading, show a spinner.  
  • If we're fetching after an error, show a spinner  
  • If none of the above and there is data, show the data.
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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

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.  
  • We introduce states with "varying possibility". As mentioned in the note above ☝️, 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.  
  • We put a lot of responsibility into consumer-land without any type-inferred guidance on how to correctly approach it.  
  • We lose the concept of exhaustiveness. There is no type-inferred guidance as to how to correctly represent these possible states.  
  • 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:

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>
Enter fullscreen mode Exit fullscreen mode

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:

(queryResult: UseQueryResult<TData, TError>) => AsyncResult<TData, TError>
Enter fullscreen mode Exit fullscreen mode

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

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
}
Enter fullscreen mode Exit fullscreen mode

Now we can use this function when fetching data:

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

  return createAsyncResult(queryResult)
}
Enter fullscreen mode Exit fullscreen mode

And then in our component:

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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:

npm install ts-pattern
Enter fullscreen mode Exit fullscreen mode

ts-pattern is a great library for pattern matching in TS. We'll use it as follows:

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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

type AsyncResult<TError, TData> =
  | NotAsked
  | Loading
  | Refreshing<TData>
  | Success<TData>
  | Failure<TError, TData>
Enter fullscreen mode Exit fullscreen mode

and concerning our component ☝️:

  • If status is 'notasked', it means the query has not been initialized, return null.  
  • If status is 'failure', it means state is of type Failure<TError, TData>, return the error.  
  • If status is 'loading', state is of type Loading, return the spinner.  
  • 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:

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.  
  • Leveraged types in a way that allows TS to give us reasonable guidance.  
  • We consolidated our data-fetching to one function that decides which states are allowed.  
  • Impossible states are gone*.  

*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

Thanks to lessp for brainstorming and correcture.

Thanks for reading!

💖 💪 🙅 🚩
momentiris
Andreas Lundqvist

Posted on October 26, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related