TanStack · GitHub

TanStack Query v5 Roadmap

Update: The roadmap is now a milestone:

This is a first draft of what a v5 could look like in terms of breaking changes. Nothing of this is set in stone, and no implementation on anything here has started. This discussion should serve as an umbrella to track things we might want to do, and to find out very early if some proposals are not a good idea.

Please feel free to add your thoughts and ideas as comments:

status: pending

see details

context

In v4, we removed the idle state because it was only used in conjunction with disabled queries and led to a bunch of impossible states with the introduction of the new fetchStatus. You couldn't be in idle state and be fetching or paused at the same time. This was the only exception to the fact that all combinations of status and fetchStatus were valid, so we removed it.

In summary, we now have:

status:

  • loading
  • success
  • error

fetchStatus:

  • idle
  • fetching
  • paused

This led to the unfortunate situation that you cannot easily check when to display a loading spinner if your query is disabled, even if you use the enabled option conditionally, e.g. for lazy queries, because the query will start in loading state if it doesn't have data yet, even if it's not fetching because it's disabled.

So you had to combine two checks (isLoading && isFetching), which is why we introduced a new flag in v4.8.0 called isInitialLoading that combines the two.


However, isLoading would be a much better name for this condition. It literally means: show a loading spinner! What we currently have as loading state is a bit misleading because it doesn't mean "data is loading", it just means "we have no data yet". It is used to discriminate the data field - if you check for isSuccess, it is guaranteed that data is defined.

A better name for this mindset would be pending. pending as in "the promise is still pending" or "we don't have data yet". It doesn't say much about data being fetched or not. A disabled query can very well be in pending state - you wouldn't necessarily show a loading spinner just because data is pending.

proposal

  • rename status: loading to status: pending and the derived boolean isLoading to isPending
  • introduce a new, derived boolean flag isLoading that is implemented as isPending && isFetching

If we do this, isLoading and isInitialLoading will have the same meaning. We could just flat out remove isInitialLoading and tell people to go back to isLoading now, but I fear that this will not land well with the community because we just told them to go from isLoading to isInitialLoading with v4.8.0.

So I would deprecate isInitialLoading with v5, but keep it intact (just an alias for isLoading really) and remove it in v6.

remove overloads

see details

context

useQuery and friends have many overloads in TypeScript - different ways how the function can be invoked. As an example, let's look at useQuery. You can call it in three different ways:

useQuery(queryKey, queryFn, options)
useQuery(queryKey, options)
useQuery(options)

The queryKey is the only required property. The queryFn is optional because you might have defined it globally. If you use the syntax where you only pass one object in, the queryKey is also required.

Not only is this tough to maintain, type wise, it also requires a runtime check to see which type the first and the second parameter are to correctly create options. Internally, we only work with one options object.

Further, we started to add more overloads for features that are only doable with overloads. For example, if you pass initialData, the return type of data will not contain undefined. Because initialData can also be a function, we need another 3 overloads for each possibility to call useQuery, resulting in 9 total overloads.

The useQuery.ts file has 140 lines of code - only 3 of which are actual JavaScript.

Other functions suffer from the same problem, and overloads are also not consistent. For example, queryCache.findAll has overloads, but mutationCache.findAll does not.

proposal

  • remove all overloads and only allow all functions to be called with a single signature - one object

This means that useQuery would need to be called:

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 1000 * 20
})

This does not only affect useQuery, but all functions that accept overloads:

  • useMutation(() => axios.post(...)) will become useMuation({ mutationFn: () => axios.post(...) })
  • invalidateQueries(["todos"]) will become invalidateQueries({ queryKey: ["todos"] })

and so on...

We have actually tried this already for v4, see:

but there was a type inference issue that stopped the attempt. The issue was fixed with TS 4.7:

mitigation strategy

We will very likely provide a codemod that automatically transforms your queries to that syntax

require minimum of TS 4.7

see details

v4 supports TS4.1 or greater, v5 will need TS4.7 or greater for the reasons mentioned above. It might work fine with TS4.1, but you might run into type inference issues.

remove custom logger

see details

context

In v3, we had a global logger that you could set via setLogger to your own logging mechanism. This was a side effect which we tried to get rid of, so in v4, you can now pass a logger prop to the QueryClient.

The logger mainly does one thing: It logs failed queries to the console. This is fine for development, but it was confusing to many that it also showed up in production.

That's why we've removed all logging in production in v4, and we've also started to use the logger more to show development specific warnings, for example:

  • when you pass a queryKey that isn't an Array.
  • when you return undefined from your queryFn.
  • when you set multiple, conflicting query defaults.

Those logs are meant to help developers find potential issues in their code. They are not meant to be shown in production. I'd see them on the same level as the warning you get from React in development mode, for example:

  • Each child in a list should have a unique "key" prop.

There is also no way to show these warnings in production. This also helps with bundle size because all logging happens behind an env check, so it is stripped from the final bundle. This means we can be as verbose as we want with those messages.


All of this means that the custom logger is kind of unnecessary. Why would you want to pass a custom logger only to log differently in development mode? The logger prop itself cannot be tree-shaken, so it will always be included in the final bundle even though we actually never use it.

proposal

  • remove the logger prop from QueryClient
  • do not log failed queries to the console anymore, as failed queries are not a programmer "error" that we need to point people towards. The network tab shows failed requests just fine.

We should be able to just use the console for those development logs as it exists in all environments.

make TError default to Error instead of unknown

see details

context

In JavaScript, you can throw anything, even literals like 5 or Promises (?? suspense). That is why the generic for the type TError defaults to unknown. This is in line with how TypeScript itself handles errors in catch clauses since v4.4.

This is not very practical. Unless you explicitly throw a non-error, error will be at least of type Error. If you use axios, it might be of type AxiosError, but that is also not guaranteed. For example, if you have a runtime error in select, that will be caught, and it puts your query into error state. The error will be of type Error then.

Practically, it means that you either fall back to runtime checks:

const { error } = useQuery(...)
if (error instanceof Error) {
  // do something with error.message
}

or, you pass a generic to useQuery:

const { error, isError } = useQuery<Todos, Error>(...)
if (isError) {
  // do something with error.message
}

The second approach is bad for two reasons:

  1. TError is the second generic, so you also have to annotate the first generic, which is the return type of the queryFn. This could actually be inferred.
  2. useQuery has 4 generics, and if you only provide two of them, the other two will fall back to their default value instead of being inferred from their usage. That means select will not work as expected:
const { error, isError } = useQuery<Todos, Error>({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: (data) => data[0],
})
if (isError) {
  // do something with error.message
}

You'll get a weird error about no overload matching, see this TypeScript Playground.

proposal

  • on type level, make the TError generic default to Error instead of unknown
  • at runtime, wrap all errors that aren't errors into errors:

This will have to happen here:

// Execute query
try {
promiseOrValue = config.fn()
} catch (error) {
promiseOrValue = Promise.reject(error)
}
catch (error) {
-  promiseOrValue = Promise.reject(error)
+  const properError = error instanceof Error ? error : new Error(String(error))
+  promiseOrValue = Promise.reject(properError)
}

rename useErrorBoundary

see details

context

A property that starts with the prefix use is unfortunately named, as use usually indicates a hook in React. ErrorBoundary might also be a quite react specific term.

Renaming the property to e.g. throwError would more accurately describe what is happening: the error will be thrown. In React, it will be picked up by the nearest error boundary.

proposal

  • rename useErrorBoundary to throwError

rename cacheTime

see details

context

Almost everyone gets cacheTime wrong (exhibit A). It sounds like "the amount of time that data is cached for", but that is not correct.

cacheTime does nothing as long as a query is still in used. It only kicks in as soon as the query becomes unused. After the time has passed, data will be "garbage collected" to avoid the cache from growing (see also this explanation).

RTK Query has the same feature - their prop is called keepUnusedDataFor. I think this is quite descriptive but also a bit long.

proposal

  • rename cacheTime to gcTime

gc is referring to "garbage collect" time. It's a bit more technical, but also a quite well known abbreviation in computer science.

Also, it is not something that most people will have to customize. The default of 5 minutes is usually fine. A rename will reduce the chance that this property is mixed up with staleTime.

Lastly, if someone doesn't immediately know what gcTime stands for, they will (hopefully) consult the docs. This is a lot better than thinking they know what cacheTime does.

Here is an old discussion on that topic:

Alternatives:

After some discussions, a rename to inactiveCacheTime would also be possible. It' similar to the current name but clearly indicates: "The time that inactive queries will be cached for". It can take a way a lot of confusion about the cacheTime being valid for all queries, even those that are actively used (= active queries). Inactive is also the naming we show in the devtools as well as when using QueryFilters, so it fits well.

size improvements

see details

context

The "big" bundle size is a constant topic when TanStack Query is compared to other libraries. I think there are a few things that can be done to improve the situation:

don't transpile optional chaining

Optional chaining is used a lot in the codebase, and it's supported in 93.44% of all browsers. Transpiling it is quite costly:

- this.retryer?.continue()
+ this.retryer == null ? void 0 : this.retryer.continue()

switch to private class fields and methods

Private class fields are supported in 92.7% of all browsers. They have a bunch of advantages:

  • they are private at runtime, not just at compile time
  • because of that, they can be minified by terser
  • as per this discussion, they would help us with circular references to private fields from within the QueryCache.

proposal

  • adapt our current supported browserslist:
- Chrome >= 73
+ Chrome >= 84
- Firefox >= 78
+ Firefox >= 90
- Edge >= 79
+ Edge >= 84
- Safari >= 12.0
+ Safari >= 15
- iOS >= 12.0
+ iOS >= 15
- opera >= 53
+ opera >= 70

Out of these changes, Safari would be the "newest" supported browser, with a release date of September 2021. This would still likely mean at least 1.5 years of browser support for Safari when we release v5.

If people want to support older browsers, they can always transpile the code themselves.

  • use private class fields and methods instead of the private TypeScript keyword.

remove isDataEqual property on useQuery

see details

We have two props that go together on useQuery that are around structural sharing:

  • isDataEqual?: (oldData: TData | undefined, newData: TData) => boolean
    • default: undefined
  • structuralSharing?: boolean | ((oldData: TData | undefined, newData: TData) => TData)
    • default: true

This is how it works internally when new data comes in, and we need to consolidate it with existing data:

  • At first, we check if isDataEqual is passed. If it is, and it returns true, we just return the previousData.
  • If it's not we used to check if structuralSharing is on (boolean). If it's not, we returned the new data
  • If structuralSharing is on, we try to re-use as much as possible from previousData to keep referential identity. This sharing is not for free, so you can turn it off. It also only works on json compatible values per default.

With v4.2.0, we added a feature for custom structuralSharing functions. This way, consumers can still achieve the performance benefits of retained references even when cache data contains non-serializable values.

This feature has a nice side effect: you can now implement isDataEqual with it. All you need to do instead is do the same check in your structuralSharing function and return oldData if they are equal instead of true. The functions also have the exact same interface for the parameters passed in:

structuralSharing: (oldData, newData) =>
  isDataEqual(oldData, newData) ? oldData : replaceEqualDeep(oldData, newData)

proposal

  • Deprecate isDataEqual in v4
    • show that you can implement it with structuralSharing. For this to work, we also need to expose the internal functionality that structural sharing is doing (currently named replaceEqualDeep).
  • Remove isDataEqual from useQuery in v5

remove contextSharing

see details

context

The QueryClientProvider has a prop contextSharing: boolean. The docs say:

Set this to true to enable context sharing, which will share the first and at least one instance of the context across the window to ensure that if React Query is used across different bundles or microfrontends they will all use the same instance of context, regardless of module scoping.

To be honest - I don't really know how this property is working. There were some discussion on this issue that suggest that it is not really useful.

For microfrontends, isolation is often preferred. With v4, we introduced the option to pass a custom context, which allows for exactly that.

If you want your app to use the same client when it's composed of multiple packages, all you'd need to do is create one QueryClient in your app and let the different bundles pick those up. As long as they all use the same version of TanStack Query, this should work fine.

If you have more info on how contextSharing works or where it is useful, please share!

proposal

  • Deprecate contextSharing in v4
  • Remove contextSharing from QueryClientProvider in v5

replace custom context with custom queryClient

see details

context

in v4, we introduced the possibility to pass a custom context to all react-query hooks. This allowed for proper isolation when using MicroFrontends. See the migration guide for examples and details.

However, context is a react only feature. All that context does is give us access to the query client. We could achieve the same isolation by allowing to pass in a custom query client instead of a custom context that then serves the query client.

vue-query does have this api already, so it makes sense to streamline it in react as well.

see also:

proposal

  • replace custom context param on react hooks with custom queryClient param

drop support for React17

see details

context

With nextJs 13 also requiring React18, it makes sense to follow suit. All new features are build on top of react18. This means we can also drop the useSyncExternalStore shim, which has caused troubles in the past with react-native and ESM, and also adds a bit of bundle size.

proposal

  • set the required peer dependency of react to ">=18.2.0"

remove remove returned from useQuery

see details

context

remove removes the query from the queryCache without informing observers about it. It is best used to remove data imperatively that is no longer needed, e.g. when logging a user out.

It doesn't make much sense to do this while a query is still active, because it will just trigger a hard loading state with the next re-render. So when would you ever use the remove function returned from useQuery, because at that point, you still have an active observer ?

anecdotal twitter thread

proposal

  • remove remove in v5

remove unstable_batchedUpdates

see details

context

we currently use unstable_batchedUpdates as our batchNotifyFn in React and React Native:

notifyManager.setBatchNotifyFunction(unstable_batchedUpdates)

Apparently, this function is a "noop" in React18, as discussed here:

react/react#24831 (comment)

Unanswered questions:

  • Do other frameworks need a batchFn ?
  • Is the same true for react-native ?
  • Should we keep the option to set a batchUpdatesFn via the notifyManager?

proposal

  • remove the default side-effect that sets batchedUpdates
  • this would also mean we would be completely side-effect free:
"sideEffects": [
"./src/setBatchUpdatesFn.ts"
],

remove queryHash from useQuery options

see details

context

The queryHash is the result of the query key hashing, produced by the queryKeyHashFn. This function defaults to a stable JSON.stringify, but can also be passed in by users to do custom hashing, e.g. when non-json serializable things are used in the queryKey.

According to our TypeScript types, we can also pass in a queryHash directly into useQuery. This is not only undocumented, it's also redundant because you can achieve the same thing with a queryKeyHashFn:

- useQuery({ queryKey, queryFn, queryHash: 'myHash' })
+ useQuery({ queryKey, queryFn, queryKeyHashFn: () => 'myHash' })

proposal

  • remove queryHash from useQuery options
    • no deprecation needed because this is undocumented

expose custom cache

see details

context

Inspired by swr cache provider, we can allow to pass a "custom cache" into our QueryCache:

new QueryClient({
  queryCache: new QueryCache({
    cache: myCache
  })
})

The cache would have to adhere to a map-like interface, and we should probably re-write our internal object to a standard Map.

This would allow features like:

  • limit cache size (as discussed here)
    • you could evict unused cache entries by other criteria than being time-based
  • persist to local storage on a per-query basis
    • limitation: writes could be async, but reads would need to be sync
  • reset cache between test runs (we can already do that in different ways though)
  • other use-cases I haven't thought of?

proposal

  • re-write internal cache object to a Map
  • allow custom caches to be passed in that adhere to a map-like interface

fix pageParams in infinite queries

see details

context

there are two issues related to pageParams that we likely cannot fix in a backwards compatible way:

proposal

- pageParams: [undefined, 5, 50],
+ pageParams: [[{ param: undefined, manual: false }, { param: 5, manual: false }, { param: 50, manual: true }]],
  • then, automatic refetches could rely on the manual flag to find out if page should be used for refetching or not.
  • disallow to return null from getNextPageParam (this would fix null is passed as pageParam with PersistQueryClientProvider #4309)
    • null and undefined would both be treated as "no next page available"
    • internally, we would store null so that we can json serialize it
- pageParams: [[{ param: undefined, manual: false }],
+ pageParams: [[{ param: null, manual: false }],
  • we would still always pass undefined to the queryFn so that default value assignment would still work:
queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),

remove placeholderData as a function

we'll do this instead:

see details

context

The real motivation is that we can remove a lot of internal code if we drop this feature, and there's no real gain to use a function over a value or a memoized value. To achieve memoization, you had to pass in a stable function (probably with useCallback). Since the function receives no input, you can just as well do this with useMemo. Unlike the initialData function, which only runs once per cache key, the placeholderData function runs on every render if placeholder data needs to be shown. So the advantage of it being potentially a function is minimal.

proposal

  • deprecate placeholderData as a function in v4
  • remove it in v5

Read the original on github.com ↗