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.
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.
Scenario
Suppose we are building a website for users to send their love messages to Pokemons. The user journey is as follows:
- A user navigates to the website.
- Components are mounted, and
useQuery()
fetches a list of Pokemons through a REST endpoint. - The UI displays each Pokemon name in a card.
- 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:
- A user visits the website.
- The first fetch for Pokemon list succeeds.
- The user clicks on one of the "Expand" buttons.
- A textarea shows up.
- The user types something but does not submit yet.
- The user pauses and goes to another browser window/tab.
- The internet connection drops.
- The user comes back, and
refetchOnWindowFocus
kicks in. - 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
andfetchStatus
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 thequeryFn
: Is it running or not?