Context
Sometimes, APIs don’t evolve well. I’ve seen the situation a couple of times that we add an API, and we think it’s great, and then after some time, we add another API that does something similar, and it also makes sense for that use case.
And as time goes on, we might do this a bunch of times, and in isolation, each little interaction made sense on its own.
But if we take a step back and look at the big picture, we might have inadvertently created something that isn’t nice to work with. It might make sense to someone who knows the “historical reasons”, but for someone coming in with a fresh set of eyes, it might look weird.
The imperative methods on the queryClient are such an example.
History
At first, we needed a function to imperatively fetch a query, so we created queryClient.fetchQuery(options) . This function respects caching and staleTime, so it will only fire a fetch if there is no fresh data in the cache, it returns a Promise that can be awaited etc. It’s not really used for displaying data in a component, but rather to combine it with things like async validations.
Then, prefetching became a thing, to e.g. fetch data when you hover a link, so that you can hopefully get the result before the user sees a loading indicator. We didn’t really want data to be returned, and we definitely didn’t want errors to be thrown, so we created a new method queryClient.prefetchQuery(options), which uses fetchQuery under the hood, but is optimized for this use-case.
And then finally, route loaders become popular, so we needed a way to integrate with those, too. Throwing errors is usually what you want to integrate with error boundaries, but we didn’t really want to “wait” in the route loaders if data was already present, because advancing to the component with stale data is usually fine, as they’ll trigger a background refetch anyways.
That’s why we added ensureQueryData, which is also to some extent built on fetchQuery. To make things worse, we even added an option to ensureQueryData to trigger background refetches (invalidateIfStale), which is awfully close to just calling fetchQuery without awaiting the promise.
Now all these steps made sense in isolation, but when you look at this, we now have 3 APIs that are pretty close in functionality. Actually, it’s 6 APIs because we need the same set of functions for infinite queries:
| “normal” queries | infinite queries |
|---|---|
queryClient.fetchQuery |
queryClient.fetchInfiniteQuery |
queryClient.prefetchQuery |
queryClient.prefetchInfiniteQuery |
queryClient.ensureQueryData |
queryclient.ensureInfiniteQueryData |
Problem Description
Now that we have those APIs, we can see a bit of confusion around them as well:
Confusion around naming
-
queryClient.fetchQuery, despite the name, might not invoke thequeryFn. If data in the cache is fresh, it will just give you that. Yes, that’s whatuseQuerydoes as well, but it’s not really reflected in the naming. -
queryClient.prefetchQueryhas a similar naming problem: thepreinprefetchQueryindicates that something is done once, before it’s needed / available. But did you know that callingprefetchQuerywill also fetch every time when data is stale? So this code:<LinkToDetailsPage id={id} onMouseEnter: () => { void queryClient.prefetchQuery(detailOptions(id)) } />
will not only put data in the cache when you hover it for the first time - it will do it on every hover interaction (given that
staleTimehas the default ofzero).
Confusion around when to use what
For route loaders, we recommend ensureQueryData. In the SSR docs, we recommend prefetchQuery. The functions are so close in functionality that it mostly doesn’t matter, so why is it two functions? We get so many questions around “what should I use where?”, which is a good indicator that the APIs are not intuitive.
Further, using prefetchQuery during SSR means that the error boundary won’t be invoked because it doesn’t throw errors. That might not matter much for server components, as errors on the server will trigger a Suspense boundary and an automatic retry on the client, but it might matter for route loaders if you want to show the errors immediately.
Current APIs
Let’s again look at the three APIs, what they do and how they differ from each other:
queryClient.fetchQuery({ ... options, staleTime })- will trigger the
queryFnunless data is already in the cache that is considered fresh (determined by the passedstaleTime) - returns a
Promise<TData>that can be awaited (might resolve immediately for fresh data). - throws errors when there is an error
- will trigger the
queryClient.prefetchQuery({ ... options, staleTime })- will trigger the
queryFnunless data is already in the cache that is considered fresh (determined by the passedstaleTime) - returns a
Promise<void>that can be awaited (might resolve immediately for fresh data). - silently discards errors
- the implementation is literally:
fetchQuery(options).then(noop).catch(noop)
- will trigger the
queryClient.ensureQueryData({ ... options, staleTime, revalidateIfStale })- will trigger the
queryFnonly if NO data is in the cache, so it doesn’t check forstaleTime - returns a
Promise<TData>that can be awaited (might resolve immediately for fresh data). - throws errors when there is an error
- checks for
staleTimeto trigger a background refetch whenrevalidateIfStaleis passed. This is meant to immediately return data and update the cache as early as possible.
- will trigger the
So, it’s undeniable that they are very similar, and the distinction by use-case isn’t really helpful, as the user needs to decide very early which case they want.
Proposed Solution
The power of useQuery comes from the fact that you have one function that you can just use with defaults and it will work as you’d expect for many cases. Then, it allows for some customization options to handle different cases on an opt-in basis. We used to have different hooks for pagination (usePaginatedQuery) but quickly moved away from that because of similar reasons: the distinction didn’t really matter.
So, we want the same for our imperative APIs, which is why we want to move towards:
queryClient.query(options) queryClient.infintiteQuery(options)
Per default, this should behave like queryClient.fetchQuery does today:
- it respects
staleTime(like any goodqueryshould) - it returns a Promise you can
await.
Migration Path
queryClient.prefetchQuery
This function will become:
`throwOnError` proposal (likely outdated)await queryClient.query(options, { throwOnError: false })
- By setting
throwOnError: false, you can re-create the part where errors aren’t thrown. This option also exists onuseQueryand other imperative methods that target multiple queries likequeryClient.refetchQueries.- Note that this isn’t really necessary in many cases - e.g. the
onMouseEnterexample from before would work fine even without changingthrowOnError, as the promise get’s ignored withvoidexplicitly. Thus, no unhandled promise rejection happens. - Note: It’s still up for debate if we’d want a second argument with
fetchOptions(like we have inrefetchQueriesor if this should just be merged withoptions.
- Note that this isn’t really necessary in many cases - e.g. the
- The result can be ignored by simply not using it - either with
voidorawait.
import { noop } from '@tanstack/react-query'
await queryClient.query(options).catch(noop)
// or
void queryClient.query(options)
- When the promise needs to be awaited, errors can be silently discarded by catching errors manually and discard them with
noop- This is very explicit and “just javascript”
- When the promise gets discarded with
void, this likely isn’t necessary as no unhandled promise rejection will happen.
- The result can be ignored by simply not using it - either with
voidorawait.
queryClient.ensureQueryData
This function will become:
const data = await queryClient.query({ ...options, staleTime: 'static' })
- We’ll allow the string literal
'static'to be set asstaleTime, which will act as an indicator to mark a query as, well,static. Static queries will never be revalidated when any data exists in the cache. The difference tostaleTime: Infinityis thatInfinityis still just a number, which means queries that are invalidated withqueryClient.invalidateQuerieswould get refetched, even if they have an infinitestaleTime(with'static', this is not the case). This was one of the main reasons to introduceensureQueryData, butstaleTime: 'static'solves this problem better- Note: This new
'static'literal can also be used anywhere else wherestaleTimeis passed, e.g. onuseQuery, and it would there too stop a query to be refetched even if it gets marked asinvalid. - Note: never refetched is not quite true:
- calling
refetchreturned fromuseQuerycan bypass this (it can bypass anything, evenenabled) - using
refetchIntervaldoesn’t usestaleTimeso it’s also unaffected
- calling
- Note: This new
Caveats
One reason why we recommend prefetchQuery in server components is the fact that it doesn’t return anything, so users can’t make the mistake of using the returned data in it. The problem you might run into when doing that is that it can get out-of-sync when a revalidation happens on the client only. There’s a great example in the [Advanced Server Rending section of the docs](https://tanstack.com/query/v5/docs/framework/react/guides/advanced-ssr#data-ownership-and-revalidation) about this.
With the new method, it would be on you to either await the data, but not use it:
await queryClient.query(postOptions)
or to simply not await it and [stream the promise to the client](https://tanstack.com/query/v5/docs/framework/react/guides/advanced-ssr#streaming-with-server-components):
void queryClient.query(postOptions)
which is likely the better approach anyways.
What about revaliateIfStale on ensureQueryData ?
Right now, I don’t think it was a good idea to introduce this functionality in the first place, so we’re not going to re-create it. If you want to read data imperatively (or fetch it if it doesn’t exist), and refetch it as well in the background if it exists but is stale, you can make two calls to queryClient.query:
// read from cache if exists, otherwise, go fetch and wait for it const data = await queryClient.query({ ...options, staleTime: 'static' }) // refetch in the background if data is older than 2 minutes void queryClient.query({ ...options, staleTime: 1000 * 60 * 2 })
Rollout strategy
We plan to add the new functions in a v5 minor and mark the existing functions as deprecated. We’ll then likely remove them in the next major version.