-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
-
ContextSometimes, 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 HistoryAt first, we needed a function to imperatively fetch a query, so we created Then, 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 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:
Problem DescriptionNow that we have those APIs, we can see a bit of confusion around them as well: Confusion around naming
Confusion around when to use whatFor route loaders, we recommend Further, using Current APIsLet’s again look at the three APIs, what they do and how they differ from each other:
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 SolutionThe power of 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
Migration Path
|
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 143 -
🎉 7 -
❤️ 34 -
🚀 31 -
👀 4
Replies: 17 comments 32 replies
-
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?
Beta Was this translation helpful? Give feedback.
All reactions
-
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
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 5
-
The only thing I can say for this RFC / new API proposal is: Beautiful
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 12
-
why
await queryClient.query(options, { throwOnError: false })
over?
await queryClient.query(options).catch(noop)
so that onError callbacks in queryClient wont get triggered?
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
I think because the first is explicit and only those aware of this concern would know to use the latter.
Beta Was this translation helpful? Give feedback.
All reactions
-
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 😅
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 3
-
Note: I’ve updated the proposal to reflect that.
Beta Was this translation helpful? Give feedback.
All reactions
-
I approve this message.
Beta Was this translation helpful? Give feedback.
All reactions
-
🎉 25 -
🚀 1
This comment has been hidden.
This comment has been hidden.
-
Indeed. I fixed it.
Beta Was this translation helpful? Give feedback.
All reactions
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
If these can be consolidated I think it would be a big win. I get confused between ensureQueryData and prefetchQuery all the time.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1 -
👀 1
-
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 🙌
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 3
-
What's difference between imperative api for query vs mutation ? We should also remove mutate together.
Beta Was this translation helpful? Give feedback.
All reactions
-
👎 4
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 5
-
I mean, the difference is a bit artifical, you can just add an options like { idempotent: false } to make it a mutation.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1 -
👎 18
-
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)
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
So I guess we should have more of a focus on where the suspense boundary is and more agnostic on the calls in loaders?
Beta Was this translation helpful? Give feedback.
All reactions
-
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
Beta Was this translation helpful? Give feedback.
All reactions
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
that’s true! I forgot about it because I’ve set pendingMs to 0 because I’m not a huge fan of that behaviour 😅
Beta Was this translation helpful? Give feedback.
All reactions
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 2
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
🚀 3
This comment has been hidden.
This comment has been hidden.
This comment has been hidden.
This comment has been hidden.
This comment has been hidden.
This comment has been hidden.
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
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:
Beta Was this translation helpful? Give feedback.
All reactions
-
🎉 1
-
Hi @TkDodo is there any progress on this? How can I help to get this feature done, I'd like to help
Beta Was this translation helpful? Give feedback.
All reactions
-
@Christopher96u I shipped the staleTime: 'static' proposal already, what's missing is:
- introduce the
queryClient.queryandqueryClient.infiniteQuerymethods - 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 😅
Beta Was this translation helpful? Give feedback.
All reactions
-
🎉 5 -
😕 1
-
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?
Beta Was this translation helpful? Give feedback.
All reactions
-
@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.
Beta Was this translation helpful? Give feedback.
All reactions
-
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
Beta Was this translation helpful? Give feedback.
All reactions
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
but all those things are so minor that my recommendation is do whatever you want 😂
very fair! 😀 thanks 👍
Beta Was this translation helpful? Give feedback.
All reactions
-
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.
Beta Was this translation helpful? Give feedback.
All reactions
-
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 :)
Beta Was this translation helpful? Give feedback.
All reactions
-
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
Beta Was this translation helpful? Give feedback.
All reactions
-
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?
Beta Was this translation helpful? Give feedback.
All reactions
-
On the
enabledandskipTokenpart, theuseQueryhook will return cached data if it's available (bypassingisStalechecks) withenabled: trueorqueryFn: 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)
Beta Was this translation helpful? Give feedback.
All reactions
-
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)
Beta Was this translation helpful? Give feedback.
All reactions
-
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: { ... } });
Beta Was this translation helpful? Give feedback.
All reactions
-
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 😅
Beta Was this translation helpful? Give feedback.
All reactions
-
@TkDodo I think it could be useful now that we have mutationOptions, but definitely not a showstopper or anything. the verbose way "works"
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 4