DEV Community

Andreas Lundqvist
Andreas Lundqvist

Posted on

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!

Top comments (5)

Collapse
 
mbylstra profile image
michaelb • Edited

I'm curious if you have added a "map" function to use with your AsyncResult type. It's very handy for transforming data. EG: let's say you want to pass in a subset of the returned data to a component, and that component also needs to know whether the data is loading/success/failure.

The type signature of the function is like this:

(result: AsyncResult<TError, TOriginalResponse>) => AsyncResult<TError, TTransformedResponse>

The closest thing I can find in react-query is the select parameter. It's not quite the same thing as you can only use it once (you can't transfrom data multiple times as you pass it down through components), but it's better than nothing.

Collapse
 
momentiris profile image
Andreas Lundqvist

And If I'm not misunderstanding you, the select param on useQuery isn't very fitting to do those transformations as it affects the original return value of useQuery. I'd probably just keep that map function and corresponding types as a util and use it directly when passing props to said component. What do you think?

Collapse
 
momentiris profile image
Andreas Lundqvist

In theory I've seen a need for something like this but I haven't actually needed it yet. But thanks that's cool, I'll check that out! Do you have any more material about the concept? Maybe some examples somewhere or similar?

Collapse
 
mbylstra profile image
michaelb

Nice article.

I found it by googling "impossible states" and "react-query" because I also found this to be a limitation of the extremely popular React Query library which I have no choice but to use at the moment.

I have worked with Elm where this style of programming (making impossible states impossible) is the norm, pattern matching is built in and there is a popular library WebData that uses a type very similar to your AsyncResult.

I totally agree this approach is nicer, and if it was for a personal project I would certainly want to do something like this (but if I was doing a side project I could use Elm instead).

However, I'm torn whether to go down this path with React/TS in a corporate environment. There are just too many hoops you have to jump through to try to get React/JS/TS behave like a functional programming language like Elm, FSharp or Haskell. There are hundreds of FP libraries to choose from for pattern matching, representing useful monads like Maybe and Result, none of which are particularly popular, so chances are you'll pick something different to others in the company. Also, people that are not familiar with this style of programming (the large majority of frontend developers) will find it very hard to read and wonder why you are overcomplicating things. They will get annoyed that they have to learn 10 different competing FP libraries when they just want to get on with their jobs. Often the libraries are not very well documented or unstable which does not help.

I've found it to be more trouble than it's worth, so I've currently decided to "disagree and commit" to doing things the conventional JS ecosystem "multi paradigm" way. It might work if you are the thought leader of a company and are able to get everyone on board but I'm finding it to be just a fight that's not worth fighting as an individual contributor.

I'm hoping the ecosystem evolves to point whether this a norm in the future. Blog posts like this certainly help move things forward - thanks for your effort!

Collapse
 
momentiris profile image
Andreas Lundqvist • Edited

Hey there and thanks for the appreciation, I'm really glad you liked the article!

Yeah, I understand the situation you're describing. I'm fortunate enough to be able to kind of decide this myself in the project I do at work. There is still some weighing in though, trying to keep the threshold rather low for other collaborators in the codebase who are unfamiliar with the "style" while still using and exploring some of these concepts. Some are easier to get into than others, so keeping the bar as low as possible is good so you minimize the risk of deterring people who are not used to the style. What I do in this post here is about as "FP" I've gone in the codebase where this is used, so far at least.

I'm myself pretty new to FP, coming mainly from a JS/TS background. It's definitely baby steps on a big learning curve but I feel like the more I learn about statically typed FP the more I resonate with it.

It's a real bummer the situation you're describing, especially when you know one way is actually better than the other :P

Thanks again!