Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

RFC: Unified Imperative Query Methods #9135

Unanswered
TkDodo asked this question in Ideas
May 10, 2025 · 17 comments · 32 replies
Discussion options

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.

You must be logged in to vote

Replies: 17 comments 32 replies

Comment options

On a related note, would it also be worth exploring a similar unification for the useXXXQuery hooks? Right now we have:

useQuery

useSuspenseQuery
useSuspenseInfiniteQuery

usePrefetchQuery
usePrefetchInfiniteQuery

They each serve a distinct purpose, but from a DX perspective, it’s starting to feel like the surface area is expanding similarly to the imperative methods you’re aiming to clean up. Could we consider an approach where these behaviours are driven more by options rather than separate exports? For instance, useQuery({ suspense: true, infinite: true }) or something along those lines.

This could make adoption and migration simpler, especially for teams navigating multiple query behaviours. Curious to hear others' thoughts—are there any strong reasons why keeping them separate is more beneficial?

You must be logged in to vote
1 reply
Comment options

TkDodo May 10, 2025
Maintainer Author

suspense and infinite both change the types in a significant way that would make it hard to combine. suspense might go away with the use() hook and using the promise returned from useQuery but it's highly experimental right now.

usePrefetchQuery is not something I'd expect you to use a lot especially if you use route loaders or SSR. I don't see this as a primary api that's worth worrying about

Comment options

The only thing I can say for this RFC / new API proposal is: Beautiful

You must be logged in to vote
0 replies
Comment options

why

await queryClient.query(options, { throwOnError: false })

over?

await queryClient.query(options).catch(noop)

so that onError callbacks in queryClient wont get triggered?

You must be logged in to vote
3 replies
Comment options

I think because the first is explicit and only those aware of this concern would know to use the latter.

Comment options

TkDodo May 11, 2025
Maintainer Author

Have a look at my comment here:

I think I’m also actually leaning more towards .catch(noop). It’s just composing / handling a promise really. I also don’t think many people will need / use that, but I could be wrong. Time will tell I guess 😅

Comment options

TkDodo May 11, 2025
Maintainer Author

Note: I’ve updated the proposal to reflect that.

Comment options

I approve this message.

You must be logged in to vote
0 replies

This comment has been hidden.

Comment options

TkDodo May 10, 2025
Maintainer Author

Indeed. I fixed it.

Comment options

I like this a lot. It was indeed confusing to me what the difference was between ensure/prefetch, nobody in the Discord could give a clear answer and even the docs didn’t really clarify if (let alone why) you should use one over the other in the context of a route loader. Your explanation above is already way better than what’s in the docs currently, I suggest adding it until this RFC is implemented.

You must be logged in to vote
0 replies
Comment options

If these can be consolidated I think it would be a big win. I get confused between ensureQueryData and prefetchQuery all the time.

You must be logged in to vote
0 replies
Comment options

I'm a heavy React Query user, and I still get confused by all the imperative methods it exposes to fetch, refetch, or invalidate data. These changes are very welcome.

I much prefer:

await queryClient.query(options).catch(noop) 

over:

await queryClient.query(options, { throwOnError: false }) 

I don't like dictating the control flow or types through props.

This solution should stay in JS land, so it's intuitive for users to handle these cases the way they normally do. JS land solutions are more intuitive than relying on command spacing to check if a property exists in a specific argument position or searching through docs to check if there is a property that solves my problem.

By JS land, I mean: if you know JavaScript, you know how to handle this.

What if I want to trigger a toast on error without interrupting the call stack? I'd have to opt out of one API and switch to another just for that use case. The .catch(() => {}) approach offers a unified mental model for handling this.

Expanding the props pattern can lead to monstrosities like returnPromise: boolean or returnErrorAsTuple: boolean, once you accept the idea of controlling flow through props.

I love that you separated the revalidateIfStale functionality instead of making it a first class behavior. It shouldn't be, in my opinion. It's always better to build custom behavior by composing core primitives.

You must be logged in to vote
1 reply
Comment options

TkDodo May 11, 2025
Maintainer Author

I don't like dictating the control flow or types through props.

Both is possible actually. You can also do .fetchQuery(options).catch(noop) right now. I wanted to add throwOnError because it’s an option we already have on other imperative query methods like invalidateQueries. It will be on a second argument and likely won’t be used by many people, but we get that almost "for free".

But yeah, I get your point. I might just not add it right now and bring it back when it gets demanded. If this isn’t used, maybe the better way would be to remove that option from invalidateQueries as well because it defaults to throwing and yeah, you can always void or .catch(noop) yourself as you said.

Also we have throwOnError on a queryObserver and there it works a bit differently (different types, different defaults, different behaviour actually) so maybe not adding it here is a good step. Thanks for the feedback 🙌

Comment options

What's difference between imperative api for query vs mutation ? We should also remove mutate together.

You must be logged in to vote
2 replies
Comment options

TkDodo May 11, 2025
Maintainer Author

The distinction between queries and mutations is still important. Queries are idempotent functions that can be executed an arbitrary amount of times; Mutations are for side-effects that you want to trigger imperatively. Calling .mutate() will run the mutationFn every time, for queryClient.query() that’s not the case.

Comment options

I mean, the difference is a bit artifical, you can just add an options like { idempotent: false } to make it a mutation.

Comment options

Think this makes a lot of sense and should be easier to explain these concepts in questions about router loaders and the like. I've been explaining the differences myself not necessarily off any docs but this talk Tanner did here on critical data (https://youtube.com/clip/UgkxNlG5s7DDYRnilAg0sV3KLFWUgrswtFDM). I guess the new docs for route loaders is use Static and await query for critical data, and not await non-critical data (where the choice of stale time matters a bit less)

You must be logged in to vote
6 replies
Comment options

So I guess we should have more of a focus on where the suspense boundary is and more agnostic on the calls in loaders?

Comment options

TkDodo May 11, 2025
Maintainer Author

if the only reason to "await" in the loader is for "critical" data, then I think you can get the same thing with not awaiting + letting the component throw to the suspense boundary.

that is specific to situations where you use suspense in a client-only env though

Comment options

I'm currently trying to get a set up to see what potential differences there are between 'await' vs 'no await' and there is one key difference with Tanstack Router; it doesn't render the pending component until one second has elapsed with the await in the loader, whereas no await, the query suspends and you see the pending component immediately. (see https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#showing-a-pending-component). That's only specific to tanstack router mind, and I won't call it in scope for query. Still trying to get this set up to give me numbers.

Comment options

TkDodo May 15, 2025
Maintainer Author

that’s true! I forgot about it because I’ve set pendingMs to 0 because I’m not a huge fan of that behaviour 😅

Comment options

Yeah it's a bit unintuitive. I could make the argument that if the component suspends it should have the same effect as awaiting the loader. Will follow up on the router repo/discord channel.

Comment options

I like this proposal a lot. I've looked up which of these is the right one sooo many times. Making the functionality explicit seems like the right move.

You must be logged in to vote
0 replies
Comment options

TkDodo
May 12, 2025
Maintainer Author

Small update: Not sure why I thought we need a unique symbol, but it has been pointed out to me that just using the string literal 'static' would also work and yeah, that's totally right and I think it's better.

That’s also consistent with how we allow e.g. refetchOnWindowFocus: boolean | 'always', which is also a string literal.

I’ve updated the RFC accordingly.

You must be logged in to vote
3 replies

This comment has been hidden.

This comment has been hidden.

This comment has been hidden.

Comment options

I like the idea of making the API more streamlined.
Im the author of the revaliateIfStale api and I think removing it is a good idea.

The reason why I have implemented this was because I thought that fetchQuery and useQuery was behaving the same.
Apperently fetchQuery doesn't refetch the data when stale while useQuery does.
Maybe that's something to consider? Having the fetchQuery and useQuery API more consistent with each other?
This does required a major change but this new proposal already does so.

While we are at it and I know you (@TkDodo) are against this.
It happens a lot in my code where I have the same mutations at multiple locations of the app.
Having a mutateOptions api really simplifies this process so I don't need to write the callback functions multiple times.
Because optimistic updates, rollbacks, updating collection and single resources could be quite extensive.

You must be logged in to vote
1 reply
Comment options

TkDodo May 13, 2025
Maintainer Author

Apperently fetchQuery doesn't refetch the data when stale while useQuery does.

on the contrary - fetchQuery ignores stale data and will wait for new data to come in, while useQuery will immediately give you stale data because it can update itself once fresh data comes in, as it’s an observer. That wouldn’t make sense for await fetchQuery or await query because if we give stale data immediately, there’s no way re-run this part once fresh data comes in.

Having a mutateOptions api really simplifies this process

We have a PR open for that:

Comment options

Hi @TkDodo is there any progress on this? How can I help to get this feature done, I'd like to help

You must be logged in to vote
1 reply
Comment options

TkDodo Aug 2, 2025
Maintainer Author

@Christopher96u I shipped the staleTime: 'static' proposal already, what's missing is:

  • introduce the queryClient.query and queryClient.infiniteQuery methods
  • deprecate the other methods
  • use the new method in tests internally instead of the deprecated one (apart from things that actually test the functions themselves)

this could be done in steps.

I had a discussion with @Ephem about this:

void queryClient.query(...)

as replacement for prefetchQuery, and I’m not sure if we concluded anything definitive, but as far as I remember there were some concerns about errors being thrown from that. I think I was wrong to assume that this doesn’t yield an "unhandled promise rejection", also there were some SSR concerns but I hope @Ephem remembers those 😅

Comment options

Question not directly related to the discussion here (although I guess, made moot by its implementation).

For route loaders, we recommend ensureQueryData

That does not match the documentation. https://tanstack.com/query/v5/docs/framework/react/guides/prefetching#router-integration uses prefetchQuery.

I guess with just query the answer would change, but in the meantime maybe update the docs to reflect the current suggestion?

You must be logged in to vote
4 replies
Comment options

@SimenB depends on if the data is necessary on first load or not. With ensureQueryData the loader waits before rendering the page where prefetchQuery fetches in the background.
This way you can’t use useSuspenseQuery because the data could still be loading and you need to handle loading state inside the component.

Comment options

Waiting is just decided by me awaiting or not, no? To me, it looks like the diff is that ensureQueryData is prefetchQuery with staleTime: Infinity or something like that (if you pass the revalidateIfStale flag). Which I, again, is part of why this discussion was raised to begin with 😀 But having the docs reflect what the maintainers think is the cleanest solution would be great

Comment options

TkDodo Oct 16, 2025
Maintainer Author

where prefetchQuery fetches in the background.

that’s not correct.

Waiting is just decided by me awaiting or not, no?

this is correct.

Again, the only difference is that prefetchQuery swallows errors so you don’t get route error boundaries shown when it fails (not sure if that’s a good default for route loaders), prefetch doesn’t return any data (likely irrelevant) and prefetch will run again when data is invalidated, which is okay / good if you don’t await because it will trigger the refetch early but it’s different if you await because it will block again (which could be what you want, or not).

but all those things are so minor that my recommendation is do whatever you want 😂 . we’ll fix that with the new method.

Comment options

but all those things are so minor that my recommendation is do whatever you want 😂

very fair! 😀 thanks 👍

Comment options

Is it still the case that'd you'd want this done in steps? I've given implementing this a shot in a branch on my fork and replacing the usages of the old methods in tests are a lot.

You must be logged in to vote
7 replies
Comment options

TkDodo Nov 1, 2025
Maintainer Author

Would need to think how to tackle the notes you have, they sound like extra PR's to me.

If we release fetchQuery, we can’t change how it behaves anymore, so we have to figure this out right away I’m afraid :)

Comment options

made an attempt to add in select, banging my head against the infinite query types at the moment.
enabled and skipToken, i'll do afterwards but I'd imagine that'll be generic spagetti

Comment options

On the enabled and skipToken part, the useQuery hook will return cached data if it's available (bypassing isStale checks) with enabled: true or queryFn: skipToken. should we do the same here?

Comment options

TkDodo Dec 30, 2025
Maintainer Author

On the enabled and skipToken part, the useQuery hook will return cached data if it's available (bypassing isStale checks) with enabled: true or queryFn: skipToken. should we do the same here?

Not sure what you mean. If an observer is disabled, it will not bypass isStale checks. It will return data if there is any and won’t do anything if there is no data - it will stay in status: 'pending' and fetchStatus: 'idle'.

That’s the general problem. If someone calls const data = await queryClient.query(options) where the queryFn is a skipToken or enabled is false, I’m not sure what should happen if there is no cached data_

  • We can’t resolve with anything because we don’t have data
  • We can return a never resolving promise but that sucks (I think this is what you get with the promise returned from useQuery though)
  • We can throw an error (might be best, not sure)
Comment options

TkDodo Dec 30, 2025
Maintainer Author

or we could continue to not support this (ignore the option and run the queryFn) and document that imperative functions will always run (like they do now)

Comment options

Now that we mutationOptions, I would love an imperative way to run mutations as well.

I've been using useMutation+mutationOptions to coordinate the cache updating. I'm looking at executing those same mutationOptions in a tanstack/start loader. It would be nice to be able to have a nicer, imperative ui:

queryClient.mutate(myMutationOptions, data);
// or (not as good)
queryClient.createMutation(myMutationOptions).exec(data);

Right now I'm doing this, which seems to work but is verbose.

context.queryClient
 .getMutationCache()
 .build(context.queryClient, userStateMutationOptions)
 .execute({ data: { ... } });
You must be logged in to vote
2 replies
Comment options

TkDodo Dec 27, 2025
Maintainer Author

Right now I'm doing this, which seems to work but is verbose.

yeah this is the way, I think we had this as a method as queryClient.executeMutation a while back but nobody seemed to use it / miss it 😅

Comment options

@TkDodo I think it could be useful now that we have mutationOptions, but definitely not a showstopper or anything. the verbose way "works"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

AltStyle によって変換されたページ (->オリジナル) /