In react-query, when you handle "error" status, you actually handle two different error states

The "error" status is actually divided into "loading error" state and "refetch error" state



What might not be obvious when using react-query is that the "error" status is actually divided into two distinct states: "loading error" and "refetch error". In this article, we will take a closer look at these two error states and how to handle them correctly.

The single "error" state in pre-react-query era

In the past, when we fetched data from a REST endpoint using useEffect(), we typically had only three states: "loading", "error", and "success". While state handling is simple, this useEffect() approach had its limitations, including the lack of caching and refetch capabilities.

3 states when using useEffect

In react-query, the single "error" status consists of two states

At first glance, if you use the previous mental model or look at the status provided by useQuery(), you might think that there is only one "error" state.

const query = useQuery(...)
console.log(query.status) // 'loading' | 'error' | 'success'

This is incorrect. As react-query adds more features like refetchOnWindowFocus, it also increases the total number of possible states. For the purposes of this article, we will only focus on the "error" status.

As shown in the diagram below, the "error" status actually consists of two different states: "loading error" and "refetch error". This information is crucial to correctly design your UI. In some cases, we may handle both of them similarly and display the same UI. However, there are some scenarios that may cause unexpected UI states if we don't handle them separately.

react-query has two different error states

Scenario

Suppose we are building a website for users to send their love messages to Pokemons. The user journey is as follows:

  1. A user navigates to the website.
  2. Components are mounted, and useQuery() fetches a list of Pokemons through a REST endpoint.
  3. The UI displays each Pokemon name in a card.
  4. Each card can be expanded. When it's expanded, there is a textarea for sending a message.

The happy path looks good. But, how can we handle unhappy paths? Let's explore two different approaches.

Approach 1: Render elements based on status only

In this approach, we handle states by checking for status as we normally would. It works fine and covers the "error" status, except in one edge case. What if:

  1. A user visits the website.
  2. The first fetch for Pokemon list succeeds.
  3. The user clicks on one of the "Expand" buttons.
  4. A textarea shows up.
  5. The user types something but does not submit yet.
  6. The user pauses and goes to another browser window/tab.
  7. The internet connection drops.
  8. The user comes back, and refetchOnWindowFocus kicks in.
  9. Issue: All content and the textarea disappeared. The user lost their in-progress message.

Let's see the code and the result.

function App() {
  const query = useQuery(
    ['pokemons'],
    async () => {
      const {
        data,
      }: {
        data: { results: { name: string; url: string }[] }
      } = await axios.get('https://pokeapi.co/api/v2/pokemon')
      return { pokemons: data.results }
    },
    { retry: 0 }
  )
  return (
    <div>
      <h1>Home Page</h1>
      {(() => {
        switch (query.status) {
          case 'loading':
            return <div>Status: Loading...</div>
          case 'error':
            return <div>Status: Error</div>
          case 'success':
            return query.data.pokemons.map((poke) => {
              return <Card key={poke.name} pokeName={poke.name} />
            })
        }
      })()}
    </div>
  )
}

Codesandbox: https://codesandbox.io/s/rq-error-1-24f9n2?file=/src/App.tsx

In the example above, I imitated an internet connection drop by blocking the request. You will see that a "refetch error" blows away the whole page since we treat it like an initial "loading error". What can we do to preserve the textarea even if there is a "refetch error"?

Approach 2: Fix the issue in Approach 1 by handling "loading error" and "refetch error" separately

In Approach 1, we saw that relying solely on the status can cause issues when dealing with different error states. In this approach, we will instead handle each error state separately using the help of isLoadingError and isRefetchError.

function App() {
  // ...

  return (
    <div>
      <h1>Home Page</h1>
      {/* ------ Handle each error state differently ------ */}
      {(() => {
        if (query.status === 'loading') return <div>Status: Loading...</div>

        // "loading error": no data displayed
        if (query.isLoadingError) return <div>Status: {query.status}</div>

        // "refetch error": display data in the cache, don't tear down the page
        if (query.isRefetchError || query.status === 'success') {
          return query.data.pokemons.map((poke) => {
            return <Card key={poke.name} pokeName={poke.name} />
          })
        }
        
        throw new Error('Unhandled state!!!')
      })()}
      {/* -------------------------------------------------- */}
    </div>
  )
}

Codesandbox: https://codesandbox.io/s/rq-error-2-92s446?file=/src/App.tsx

By handling "loading error" and "refetch error" state separately, we ensure that our users can continue writing their message even if they switch browser tabs and lose internet connectivity.

Summary

react-query comes with their wonderful features like refetchOnWindowFocus. It's also important to understand that there will be more possible states created by these features. Although this article mainly focuses on "error" status, the other status actually consist of different states as well. This is mentioned in the official documentation.

Background refetches and stale-while-revalidate logic make all combinations for status and fetchStatus possible. For example:

  • a query in "success" status will usually be in "idle" fetchStatus, but it could also be in "fetching" if a background refetch is happening.
  • a query that mounts and has no data will usually be in "loading" status and "fetching" fetchStatus, but it could also be "paused" if there is no network connection.

So keep in mind that a query can be in loading state without actually fetching data. As a rule of thumb:

  • The status gives information about the data: Do we have any or not?
  • The fetchStatus gives information about the queryFn: Is it running or not?