TanStack · GitHub

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 the queryFn. If data in the cache is fresh, it will just give you that. Yes, that’s what useQuery does as well, but it’s not really reflected in the naming.

  • queryClient.prefetchQuery has a similar naming problem: the pre in prefetchQuery indicates that something is done once, before it’s needed / available. But did you know that calling prefetchQuery will 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 staleTime has the default of zero).

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 queryFn unless data is already in the cache that is considered fresh (determined by the passed staleTime)
    • returns a Promise<TData> that can be awaited (might resolve immediately for fresh data).
    • throws errors when there is an error
  • queryClient.prefetchQuery({ ... options, staleTime })
    • will trigger the queryFn unless data is already in the cache that is considered fresh (determined by the passed staleTime)
    • 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)
  • queryClient.ensureQueryData({ ... options, staleTime, revalidateIfStale })
    • will trigger the queryFn only if NO data is in the cache, so it doesn’t check for staleTime
    • returns a Promise<TData> that can be awaited (might resolve immediately for fresh data).
    • throws errors when there is an error
    • checks for staleTime to trigger a background refetch when revalidateIfStale is passed. This is meant to immediately return data and update the cache as early as possible.

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 good query should)
  • 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 on useQuery and other imperative methods that target multiple queries like queryClient.refetchQueries.
    • Note that this isn’t really necessary in many cases - e.g. the onMouseEnter example from before would work fine even without changing throwOnError, as the promise get’s ignored with void explicitly. 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 in refetchQueries or if this should just be merged with options.
  • The result can be ignored by simply not using it - either with void or await .
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 void or await .

queryClient.ensureQueryData

This function will become:

const data = await queryClient.query({ ...options, staleTime: 'static' })
  • We’ll allow the string literal 'static' to be set as staleTime, 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 to staleTime: Infinity is that Infinity is still just a number, which means queries that are invalidated with queryClient.invalidateQueries would get refetched, even if they have an infinite staleTime(with 'static', this is not the case). This was one of the main reasons to introduce ensureQueryData, but staleTime: 'static' solves this problem better
    • Note: This new 'static' literal can also be used anywhere else where staleTime is passed, e.g. on useQuery , and it would there too stop a query to be refetched even if it gets marked as invalid.
    • Note: never refetched is not quite true:
      • calling refetch returned from useQuery can bypass this (it can bypass anything, even enabled)
      • using refetchInterval doesn’t use staleTime so it’s also unaffected

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.

Read the original on github.com ↗