React Query Patterns
Last updated at
Customizing the Defaults
- Global configuration in
new QueryClient()
- Control a subset of queries with
setQueryDefaults
- For a specific query set the options directly in
useQuery
Additionally, all options in useQuery
(except for queryKey
) can have
a default value, even the query function.
Validating Query Response
The benefit of using zod
-
Saves memory in the cache by stripping un-specified fields
-
Throws errors when data doesn’t match
Pre-filling with initialData
We can use initialData
to pre-fill the query cache in two ways
- pass results from server components to client components to save the first fetch
- when a query is a subset of another query (e.g., fetching the completed todos is a subset of fetching all todos), we can use the query data from the parent query to pre-fill the child query, source
Another example of pre-filling an id-based query
Difference Between initialData
and placeholderData
initialData
works on cache level, while placeholderData
works on
observer level.
-
caches are identified by query keys, while observers are subscriptions created by
useQuery
calls. Example settings that affect the cache entry arequeryFn
andgcTime
, while settings that affect the observer areselect
andrefetchInternal
. -
initialData
is persisted to the cache.placeholderData
on the other hand is never persisted to the cache. I like to see it as “fake-it-till-you-make-it” data. It’s “not real”. -
refetch is triggered immediately regardless of presence of placeholderData, because it’s not “real”. But, if you provide
initialData
, react query will wait afterstaleTime
before refetching.
Conditional initialData
Only set initial data if the data available is updated recently.
Only set initial data if it’s the first page
Seeding with Pushing or Pulling
Setting initialData
is a form of seeding, this is often used when we
need a query to fetch a list of items as well as queries to fetch
individual items. Here are two common patterns
- Pulling: when
initialData
is needed for the single item, search the item in the list, if not found, fetch it from the server
Pulling is the recommended approach because it seeds “just in time”,
the only downside is that you need an extra initialDataUpdatedAt
to
make sure react query respects the stale time.
- Pushing: when the list query is resolved, seed each entry to their
individual queries with
queryClient.setQueryData()
With pushing staleTime
is automatically respected, because the seed
happens at the same time as the list fetch. But this might create
unnecessary cache entries and the pushed data might be garbage collected
too early.
Prefetching
Seeding is useful when you have the exact data that is needed for future queries. When working with relational data, e.g. a feed and its comments, we don’t get the comments when you fetch the feed, but we can prefetch the comments when we fetch the feed in parallel.
Another way is to prefetch inside of the query function. This makes
sense if you know that every time an article is fetched it’s very likely
comments will also be needed. For this, we’ll use
queryClient.prefetchQuery
:
If the primary query is a suspense query, you should not put the prefetch query inside the same component, because that component is unmounted before the suspense query resolves, and the prefetch query will only kick off until the suspsense query resolves. You can prefetch “one level up” in the parent component.
Separate Client and Server State
Use a query to pre-fill some user inputs, if the input is not touched, keep the query running, if the input is touched, stop the query and use the user input.
In the following example, input is bind to value
, which can be either
the draft
state or the query data, as long as the user starts typing
the draft state takes precedence and enabled is will be false
.
Data Transformations
You can use the select
option to select a subset of the data that your
component should subscribe to. This is useful for highly optimized data
transformations or to avoid unnecessary re-renders.
A component using the useTodoCount
custom hook will only re-render if
the length of the todos
changes. It will not re-render if e.g. the
name of a todo has changed.
In contrast to transforming the data directory after useTodo
, which
runs during every re-render or when the query data changes, the select
option is a more efficient way to transform data.
Error Handling
- Use the
error
property returned fromuseQuery
- Use the
onError
callback (on the query itself or the globalQueryCache
/MutationCache
)
- Use Error Boundaries
Set throwOnError
to true
to throw an error when the query fails,
which can be caught by an error boundary.
It’s possible to have more granular control over error handling by
passing a function to throwOnError
such that only when the function
return true
, the error will be thrown
Pattern: handle refetch errors globally with toast messages and handle initial load errors with error boundaries
Reset Error Boundaries
Query errors can be reset with the QueryErrorResetBoundary
component
or with the useQueryErrorResetBoundary
hook.
When using the component it will reset any query errors within the boundaries of the component:
Suspense Queries
-
there is no
enabled
option foruseSuspenseQuery
, because multiple suspense queries run sequentially so the dependency is expressed by the order of the queries -
there is no
placeholderData
for suspense queries, but you can simulate the same pagination example with thestartTransition
hook.
Mutations
-
useMutation
is imperative, whileuseQuery
is declarative. You need to call the returnedmutate
function yourself to trigger the mutation. -
mutationKey
is not required foruseMutation
and has nothing to do with query keys. Set it to the same as a query key does not revalidate that query, usequeryClient.invalidateQueries
instead. -
When you need to share states of a mutation across components, set
mutationKey
and useuseMutationState
to access the state.
Mutation Callbacks
Callbacks such as onSuccess
, onError
and onSettled
can be set on
useMutation
as well as on mutate
itself. One difference is that the
callbacks on useMutation
fire before the callbacks on mutate
.
Further, the callbacks on mutate
might not fire at all if the
component un-mounts before the mutation has finished.
Rule of thumbs to separate concerns between callbacks in useMutation
and mutate
:
-
do absolutely necessary logic (such as query invalidation) in
useMutation
callbacks -
Do UI related things like redirects or showing toast notifications in mutate callbacks. If the user navigated away from the current screen before the mutation finished, those will purposefully not fire.
Invalidation After Mutation
The simplest form of invalidation is to invalidate a single query after a mutation.
To make things more declarative, we can set a global onSuccess
callback on MutationCache
to search for a meta
property in the
finished mutation, if a query matches the meta property, invalidate it.
Optimistic Updates
Optimistic updates can be implemented in 2 ways
- after
mutationFn
is called, displayvariables
(the input supplied tomutationFn
) fromuseMutation
oruseMutationState
, this is directly manipulating the UI
Because we are awaiting invalidateQueries
, isPending
will only be
false
when the invalidation is finished. So when the opacity item is
removed, we will see the most up-to-date data.
This approach can be problematic we if you click on multiple checkboxes
in a row, there is a going to be a gap between the several
invalidateQueries
. For example, if the second todo we clicked on is
originally checked, it is now unchecked, if the first invalidation
finishes now, it will try to update the second todo to be checked again,
because that’s what the server state is now. It will fix itself after
the last mutation has succeeded and the queries have been invalidated,
but it’s not a great experience for the user.
The root of this problem is that we are only not changing the cache until the mutation is finished. We can fix this by updating the cache immediately after the mutation is called, and then revert the cache if the mutation fails.
- Second method: use the
onMutate
callback to manipulate the cache, the gist is that wesetQueryData
with the new data, and if the mutation fails, we revert the cache to the previous state. The value returned byonMutate
can be accessed via thecontext
argument inonError
andonSettled
.
You can also return a function from onMutate
to serve as a rollback
Query and Mutation Cancellation
Queries can be canceled manually with
queryClient.cancelQueries({queryKey})
.
As an alternative, if your queryFn
understands the signal from
AbortController
, queryFn
are given a signal
parameter, which comes
from an AbortController
object react query creates under the hood.
Imagine you are making queries based on a search input, it is helpful to
cancel previous queries except for the latest one.
Cancellation does not work when working with Suspense hooks:
useSuspenseQuery
, useSuspenseQueries
and useSuspenseInfiniteQuery
.
Typed Query Options
While the useQuery
generic is automatically typed by queryFn
,
methods such as queryClient.getQueryData({ queryKey })
does not have
access to the query function type. To solve this, we can co-locate the
queryKey
and queryFn
using the queryOptions
helper and use it in
both useQuery
and getQueryData
.
Parallel Queries
Multiple useQuery
observers are already running in parallel by
default.
But there are cases where the queries are dynamic and can’t be laid out
statically. In such cases, we can use useQueries
to dynamically
generate multiple query options object:
The return value of useQueries
is an array of individual useQuery
results, and you can process the array however you want.
If queries
in useQueries
is an empty array, it won’t do anything.
This is useful to implement dependent queries similar to the enabled
option.
In the following example, we fetch a list of repos and then fetch the
issues for each repo. We use useQueries
to dynamically generate the
issue queries.
An alternative to dynamic useQueries
is creating a separate component
for each item and use useQuery
inside it. A downside of this approach
is that it’s hard to derive values based on all the queries, e.g. to get
the total number of issues. If we are using useQueries
, we can just
loop through the queries array.
useQueries
provides a combine
argument for this use case, what is
returned from combine
will be the result of the useQueries
hook.
combine
is useful even when you don’t need to derive an aggregation.
You can simply use it to reshape the return values so it fits your
component’s needs.
Pagination
-
use
placeholderData: keepPreviousData
to prevent loading spinners when the page changes -
use
isPlaceholderData
to provide loading feedback and disable pagination buttons -
set up an
useEffect
to prefetch the data for the next page whenever page changes
Infinite Queries
getProjects
is a mock api that returns an object
Example:
- Infinite queries are about changing a cursor and provide it to the
fetch function.
useInfiniteQuery
returnsdata
as an 2D array of all pages
-
set
initialPageParam
to give the fetcher a starting point, and usegetNextPageParam
to provide the next cursor. How you get the next cursor depends, but you get access to the all the current data in thegetNextPageParam
function. -
fetchNextPage
triggers the fetcher with the next cursor, when you callfetchNextPage
is up to you, e.g., using a interaction observer -
if
getNextPageParam
returns undefined or null.hasNextPage
will be set to false and you can conditionally hide the trigger -
infinite queries can be bidrectional (e.g. chat messages),
getPreviousPageParam
can be used to fetch the previous page. If the API does return a cursor, e.g., it’s built for pagination, we can create cursor ourselves
-
we use a single query key for all pages, this means that all pages are treated as a single cache entry and will be revalidated altogether. This can become a problem
-
the cache entry can become very large
-
when the query becomes stale and needs to be refetched, each group is fetched sequentially, starting from the first one. This is the only way to ensure we have the most up-to-date data for all pages.
-
Set maxPages
to limit the amount of pages that are kept in the cache.
Offline Support with networkMode
Both useQuery
and useMutation
have a networkMode
option that has 3
values
networkMode = 'online'
: the default mode. This means that the query and mutation rely on the network to do stuff. If we go offline, the query and mutation goes into paused state automatically. A related note of this is that you should not use theisLoading
(and should useisPending
) to show a loading spinner, becauseisLoading=false
when the query is paused.
-
networkMode = 'always'
: this mode means that your queries and mutations does not need network and access.-
Queries will never be paused because you have no network connection.
-
Retries will also not pause - your Query will go to error state if it fails.
-
refetchOnReconnect
defaults tofalse
in this mode, because reconnecting to the network is not a good indicator anymore that stale queries should be refetched. You can still turn it on if you want.
-
-
networkMode = 'offlineFirst'
: the first request will always be made (possibly without network connection), and if that fails, retries will be paused. This mode is useful if you’re using an additional caching layer like the browser cache on top of React Query. For example, the Github API sets the browser cache as
which means that for the next 60 seconds, if you request that resource again, the response will come from the browser cache.
In this case, we would want to activate react query even if we are offline, because chances are that the browser cache has the data we need. And if you have a cache miss, you’ll likely get a network error, after which React Query will pause the retries, which will put your query into the paused state. It’s the best of both worlds.
Offline Mutations
All things mentioned around networkMode
apply to mutation equally. One
additional thing to note is that we often invalidate the cache in the
onSettled
callback of a mutation.
When we go offline in the middle of a mutation, the mutation is paused,
and onSettled
will be invoked after we go back online again and the
mutation is finished. In contrast, onMutate
fires before the mutation
function so that our optimistic updates in there can be seen regardless
of the network status.
One problem is, if we have multiple ongoing mutations that are brought back after we go online. We will be running multiple invalidations, which might cause the UI to update multiple times. To avoid this, we can check of the number of ongoing mutations and only invalidate the cache if it’s the last one.
Persistence
Notes for the code above:
-
defaultShouldDehydrateQuery
is a helper function that only persists successful queries and respects other react query’s default persist logic, the same asdefaultShouldDehydrateMutation
-
set
gcTime
as equal or greater thanmaxAge
to avoid queries being garbage collected and removed from the storage too early -
mutations and their input can be saved to storage as well, we also set the default mutation function for the mutation key so that when restoring mutations by key, react query doesn’t need a look up for the mutation function
As an experimental feature, we can now set persist
per query
The default options for the persister are
Using with SSR
https://tanstack.com/query/latest/docs/framework/react/guides/ssr
An example of RSC streaming and prefetching data on the server (don’t await the prefetch)