React hosts a number of solutions to various design problems due to its flexible paradigm. The decisions we make at the design and architecture phase of a project can either alleviate the time cost of development for a simple robust solution, or hinder it due to taxing implementations.
One easy-to-implement yet sometimes tricky to use tool is react-query
- a powerful library for asynchronous state management. It's simplicity in implementation makes it a desireable choice for writing component state logic.
However, there are some unspoken aspects of React Query that may seem frustrating, and increase its difficulty, yet often fall on the context of the problem we hope to resolve.
The next few concepts demonstrate some patterns for simple solutions while addressing some nuances one might encounter along the way.
Note that react-query
is now TanStack Query, and these concepts can be used in Vue, Solid, and Svelte. It'll continue to be referred to as React Query (RQ) in this React article.
Understanding Query State Keys
Under the hood, RQ performs some mappings similarly to "typed" actions works in other state machines. Actions, or in our case, queries, are mapped to a key where the value is either null
, or some initial state (more on this in the following section).
Because RQ is a state management tool, state will update relative to other stateful changes within a component. This means that whenever state changes in a component, these changes also affect the query's state performing state checks, and update accordingly.
Take a Todo app that loads some tasks. The snapshot of the query's state can look like:
{
queryKey: {
0: 'tasks
},
queryHash: "[\"tasks\"]"
}
If the state key changes, so does the cached data accessible at the key at that moment. State changes will only occur on a few select events. If the state key doesn't change, the query can still run, but the cached data won't update.
Using Initial and Placeholder Data
An example of this is with the use of initial data to represent values that haven't been fully loaded. It's common in state machines to introduce an initial state before hydration.
Take the Todo app earlier that needs to show an initial loading state. On initial query, the tasks query is in loading
status (aka isLoading = true
). The UI will flicker this content in place when it's ready. This isn't the best UX, but it can be fixed quickly.
RQ provides options for settings initialData
or placeholderData
. Although these properties have similarities, the difference is where caching occurs: cache-level vs observer-level.
Cache-level refers to caching via Query Key, which is where initialData
resides. This initial cache overrides observer-level caches.
Observer-level refers to the location the subscription lives, and where placeholderData
renders. Data at this level won't cache, and works if no initial data was cached.
With initialData
, you have more control over cache staleTime
and refetching strategies. Whereas, placeholderData
is a valid option for a simple UX enhancement. Keep in mind that error states change with the choice between caching initial data or not.
export default function TasksComponent() {
const { data, isPlaceholderData } = useQuery<TaskResponse>(
['tasks'],
getTasks,
{
placeholderData: {
tasks: [],
},
}
);
return (
<div>
{isPlaceholderData ? (
<div>Initial Placeholder</div>
) : (
<ul>
{data?.tasks?.map((task) => (
<li key={task.id}>{task.name}</li>
))}
</ul>
)}
</div>
);
}
Managing Refreshing State
Expanding on the previous concept, updating the query happens when either state is stale, and we control the refresh, or we use other mechanisms, provided by RQ, to perform the same tasks.
Re-hydrating Data
RQ exposes a refresh
method that makes a new network request for the query and updates state. However, if the cached state is the same as the newly fetched state, nothing will update. This can be a desired outcome to limit state updates, but take a situation where manual refetching is required once a mutation occurs.
Using a query key with fetch queries increases the query's specificity, and adds control refetching. This is known as Invalidation, where the Query Key's data is marked as stale, cleared, and refetched.
Refetching data can occur automatically or manually in varying degree for either approach. What this means is we can automatically have a query refetch data on stateTime
or other refetching options (refetchInterval
, refetchIntervalInBackground
, refetchOnMount
, refetchOnReconnect
, and refetchOnWindowFocus
). And we can refetch manually with the refetch
function.
Automatic refresh is rather straightforward, but there are times when we want to manually trigger an update to a specific Query Key thus fetching the latest data. However, you probably encountered a situation where the refetch
function doesn't perform a network request.
Processing HTTP Errors
One of the more common situations beginners to RQ face is handling errors returned from failed http requests. In a standard fetch request using useState
and useEffect
, it is common to create some state to manage network errors. However, RQ can capture runtime errors if the API handler used doesn't contain proper error-handling.
Whether runtime or network error, they appear in the error
property of the query or mutation (also indicated by isError
status).
To overcome this and pass network error into the query, we can either do one or a combination of:
- telling the fetch API how to process the failed response
- using error boundaries with queries and mutations
Handling Errors with Fetch
For a simple solution to handling errors, process network requests from the fetch API (or axios) like the following:
async function getTasks() {
try {
const data = await fetch('https://example.com/tasks');
// if data is possibly null
if(!data) {
throw new Error('No tasks found')
}
return data;
} catch (error) {
throw new Error('Something went wrong')
}
}
Then, in your component:
export default function TasksComponent() {
const { data, isError } = useQuery<TaskResponse>(['tasks'], getTasks);
return (
<div>
{isError ? (
<>Unable to load errors at this time.</>
) : (
<ul>
{data?.tasks?.map((task) => (
<li key={task.id}>{task.name}</li>
))}
</ul>
)}
</div>
);
}
This pattern gets repetitive, but is useful for simple apps that may not require heavy query use.
Handling Errors with Error Boundaries
Arguably the best thing a React dev can do for their app is to set an Error Boundary.
Error Boundaries help contain runtime errors that would normally crash an app by propagating through the component tree. However, they can't process network errors without some setup.
Thankfully, RQ makes this very easy with the useErrorBoundary
option:
const { data, isError } = useQuery<TaskResponse>(['tasks'], getTasks, { useErrorBoundary: true });
The query hook takes the error, caches it, and rethrows it, so the error boundary captures it accordingly.
Additionally, passing a function to useErrorBoundary
that returns a boolean increases the granularity of network error handling like:
const { data, isError } = useQuery<TaskResponse>(['tasks'], getTasks, {
useErrorBoundary: (error) => error.response?.status >= 500
});
Takeaways
The three main concepts using React Query are:
- hydrating the cache with placeholders or defaults
- re-hydrating the cache with fresh data
- handling network errors with the correct setup and error boundaries
There are a number of state management tools to use with React, but React Query makes it simple to get up and running with an effective tool that adheres to some simple patterns and React rendering cycles.
Learning how and when to execute queries to achieve the desire pattern takes some understanding of the React ecosystem.