diff --git a/docs/framework/react/guides/important-defaults.md b/docs/framework/react/guides/important-defaults.md index 57aeb524c5..8fddf5365d 100644 --- a/docs/framework/react/guides/important-defaults.md +++ b/docs/framework/react/guides/important-defaults.md @@ -9,13 +9,20 @@ Out of the box, TanStack Query is configured with **aggressive but sane** defaul > To change this behavior, you can configure your queries both globally and per-query using the `staleTime` option. Specifying a longer `staleTime` means queries will not refetch their data as often +- A Query that has a `staleTime` set is considered **fresh** until that `staleTime` has elapsed. + + - set `staleTime` to e.g. `2 * 60 * 1000` to make sure data is read from the cache, without triggering any kinds of refetches, for 2 minutes, or until the Query is [invalidated manually](./query-invalidation.md). + - set `staleTime` to `Infinity` to never trigger a refetch until the Query is [invalidated manually](./query-invalidation.md). + - set `staleTime` to `'static'` to **never** trigger a refetch, even if the Query is [invalidated manually](./query-invalidation.md). + - Stale queries are refetched automatically in the background when: - New instances of the query mount - The window is refocused - The network is reconnected - - The query is optionally configured with a refetch interval -> To change this functionality, you can use options like `refetchOnMount`, `refetchOnWindowFocus`, `refetchOnReconnect` and `refetchInterval`. +> Setting `staleTime` is the recommended way to avoid excessive refetches, but you can also customize the points in time for refetches by setting options like `refetchOnMount`, `refetchOnWindowFocus` and `refetchOnReconnect`. + +- Queries can optionally be configured with a `refetchInterval` to trigger refetches periodically, which is independent of the `staleTime` setting. - Query results that have no more active instances of `useQuery`, `useInfiniteQuery` or query observers are labeled as "inactive" and remain in the cache in case they are used again at a later time. - By default, "inactive" queries are garbage collected after **5 minutes**. diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index 0b4066dbf4..5bda1e9e53 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -90,12 +90,13 @@ const { - This function receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds. - A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff. - A function like `attempt => attempt * 1000` applies linear backoff. -- `staleTime: number | ((query: Query) => number)` +- `staleTime: number | 'static' ((query: Query) => number | 'static')` - Optional - Defaults to `0` - The time in milliseconds after which data is considered stale. This value only applies to the hook it is defined on. - - If set to `Infinity`, the data will never be considered stale + - If set to `Infinity`, the data will not be considered stale unless manually invalidated - If set to a function, the function will be executed with the query to compute a `staleTime`. + - If set to `'static'`, the data will never be considered stale - `gcTime: number | Infinity` - Defaults to `5 * 60 * 1000` (5 minutes) or `Infinity` during SSR - The time in milliseconds that unused/inactive cache data remains in memory. When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration. When different garbage collection times are specified, the longest one will be used. @@ -116,21 +117,21 @@ const { - Defaults to `true` - If set to `true`, the query will refetch on mount if the data is stale. - If set to `false`, the query will not refetch on mount. - - If set to `"always"`, the query will always refetch on mount. + - If set to `"always"`, the query will always refetch on mount (except when `staleTime: 'static'` is used). - If set to a function, the function will be executed with the query to compute the value - `refetchOnWindowFocus: boolean | "always" | ((query: Query) => boolean | "always")` - Optional - Defaults to `true` - If set to `true`, the query will refetch on window focus if the data is stale. - If set to `false`, the query will not refetch on window focus. - - If set to `"always"`, the query will always refetch on window focus. + - If set to `"always"`, the query will always refetch on window focus (except when `staleTime: 'static'` is used). - If set to a function, the function will be executed with the query to compute the value - `refetchOnReconnect: boolean | "always" | ((query: Query) => boolean | "always")` - Optional - Defaults to `true` - If set to `true`, the query will refetch on reconnect if the data is stale. - If set to `false`, the query will not refetch on reconnect. - - If set to `"always"`, the query will always refetch on reconnect. + - If set to `"always"`, the query will always refetch on reconnect (except when `staleTime: 'static'` is used). - If set to a function, the function will be executed with the query to compute the value - `notifyOnChangeProps: string[] | "all" | (() => string[] | "all" | undefined)` - Optional diff --git a/docs/reference/QueryClient.md b/docs/reference/QueryClient.md index 78fab85092..86634c2e5d 100644 --- a/docs/reference/QueryClient.md +++ b/docs/reference/QueryClient.md @@ -321,6 +321,7 @@ The `invalidateQueries` method can be used to invalidate and refetch single or m - If you **do not want active queries to refetch**, and simply be marked as invalid, you can use the `refetchType: 'none'` option. - If you **want inactive queries to refetch** as well, use the `refetchType: 'all'` option +- For refetching, [queryClient.refetchQueries](#queryclientrefetchqueries) is called. ```tsx await queryClient.invalidateQueries( @@ -390,6 +391,11 @@ await queryClient.refetchQueries({ This function returns a promise that will resolve when all of the queries are done being refetched. By default, it **will not** throw an error if any of those queries refetches fail, but this can be configured by setting the `throwOnError` option to `true` +**Notes** + +- Queries that are "disabled" because they only have disabled Observers will never be refetched. +- Queries that are "static" because they only have Observers with a Static StaleTime will never be refetched. + ## `queryClient.cancelQueries` The `cancelQueries` method can be used to cancel outgoing queries based on their query keys or any other functionally accessible property/state of the query. diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index 4f18534808..b6a5bfa81e 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -661,6 +661,35 @@ describe('queryClient', () => { expect(second).toBe(first) }) + test('should read from cache with static staleTime even if invalidated', async () => { + const key = queryKey() + + const fetchFn = vi.fn(() => Promise.resolve({ data: 'data' })) + const first = await queryClient.fetchQuery({ + queryKey: key, + queryFn: fetchFn, + staleTime: 'static', + }) + + expect(first.data).toBe('data') + expect(fetchFn).toHaveBeenCalledTimes(1) + + await queryClient.invalidateQueries({ + queryKey: key, + refetchType: 'none', + }) + + const second = await queryClient.fetchQuery({ + queryKey: key, + queryFn: fetchFn, + staleTime: 'static', + }) + + expect(fetchFn).toHaveBeenCalledTimes(1) + + expect(second).toBe(first) + }) + test('should be able to fetch when garbage collection time is set to 0 and then be removed', async () => { const key1 = queryKey() const promise = queryClient.fetchQuery({ @@ -1323,6 +1352,25 @@ describe('queryClient', () => { expect(queryFn1).toHaveBeenCalledTimes(2) onlineMock.mockRestore() }) + + test('should not refetch static queries', async () => { + const key = queryKey() + const queryFn = vi.fn(() => 'data1') + await queryClient.fetchQuery({ queryKey: key, queryFn: queryFn }) + + expect(queryFn).toHaveBeenCalledTimes(1) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + staleTime: 'static', + }) + const unsubscribe = observer.subscribe(() => undefined) + await queryClient.refetchQueries() + + expect(queryFn).toHaveBeenCalledTimes(1) + unsubscribe() + }) }) describe('invalidateQueries', () => { @@ -1537,6 +1585,25 @@ describe('queryClient', () => { expect(abortFn).toHaveBeenCalledTimes(0) expect(fetchCount).toBe(1) }) + + test('should not refetch static queries after invalidation', async () => { + const key = queryKey() + const queryFn = vi.fn(() => 'data1') + await queryClient.fetchQuery({ queryKey: key, queryFn: queryFn }) + + expect(queryFn).toHaveBeenCalledTimes(1) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + staleTime: 'static', + }) + const unsubscribe = observer.subscribe(() => undefined) + await queryClient.invalidateQueries() + + expect(queryFn).toHaveBeenCalledTimes(1) + unsubscribe() + }) }) describe('resetQueries', () => { diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index f9f21aea19..a594523b3d 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -1178,6 +1178,33 @@ describe('queryObserver', () => { unsubscribe() }) + test('should not see queries as stale is staleTime is Static', async () => { + const key = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { + await sleep(5) + return { + data: 'data', + } + }, + staleTime: 'static', + }) + const result = observer.getCurrentResult() + expect(result.isStale).toBe(true) // no data = stale + + const results: Array> = [] + const unsubscribe = observer.subscribe((x) => { + if (x.data) { + results.push(x) + } + }) + + await vi.waitFor(() => expect(results[0]?.isStale).toBe(false)) + + unsubscribe() + }) + test('should return a promise that resolves when data is present', async () => { const results: Array = [] const key = queryKey() @@ -1346,6 +1373,22 @@ describe('queryObserver', () => { unsubscribe() }) + test('should not refetchOnMount when set to "always" when staleTime is Static', async () => { + const key = queryKey() + const queryFn = vi.fn(() => 'data') + queryClient.setQueryData(key, 'initial') + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + staleTime: 'static', + refetchOnMount: 'always', + }) + const unsubscribe = observer.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(1) + expect(queryFn).toHaveBeenCalledTimes(0) + unsubscribe() + }) + test('should set fetchStatus to idle when _optimisticResults is isRestoring', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index e6ad4ca4ee..7ee01522ec 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -3,6 +3,7 @@ import { noop, replaceData, resolveEnabled, + resolveStaleTime, skipToken, timeUntilStale, } from './utils' @@ -24,6 +25,7 @@ import type { QueryOptions, QueryStatus, SetDataOptions, + StaleTime, } from './types' import type { QueryObserver } from './queryObserver' import type { Retryer } from './retryer' @@ -270,26 +272,44 @@ export class Query< ) } - isStale(): boolean { - if (this.state.isInvalidated) { - return true + isStatic(): boolean { + if (this.getObserversCount() > 0) { + return this.observers.some( + (observer) => + resolveStaleTime(observer.options.staleTime, this) === 'static', + ) } + return false + } + + isStale(): boolean { + // check observers first, their `isStale` has the source of truth + // calculated with `isStaleByTime` and it takes `enabled` into account if (this.getObserversCount() > 0) { return this.observers.some( (observer) => observer.getCurrentResult().isStale, ) } - return this.state.data === undefined + return this.state.data === undefined || this.state.isInvalidated } - isStaleByTime(staleTime = 0): boolean { - return ( - this.state.isInvalidated || - this.state.data === undefined || - !timeUntilStale(this.state.dataUpdatedAt, staleTime) - ) + isStaleByTime(staleTime: StaleTime = 0): boolean { + // no data is always stale + if (this.state.data === undefined) { + return true + } + // static is never stale + if (staleTime === 'static') { + return false + } + // if the query is invalidated, it is stale + if (this.state.isInvalidated) { + return true + } + + return !timeUntilStale(this.state.dataUpdatedAt, staleTime) } onFocus(): void { diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 3406bbf7d2..650d3e5ad6 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -324,7 +324,7 @@ export class QueryClient { const promises = notifyManager.batch(() => this.#queryCache .findAll(filters) - .filter((query) => !query.isDisabled()) + .filter((query) => !query.isDisabled() && !query.isStatic()) .map((query) => { let promise = query.fetch(undefined, fetchOptions) if (!fetchOptions.throwOnError) { diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 174dc72bf5..f2b961eb27 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -764,7 +764,10 @@ function shouldFetchOn( (typeof options)['refetchOnWindowFocus'] & (typeof options)['refetchOnReconnect'], ) { - if (resolveEnabled(options.enabled, query) !== false) { + if ( + resolveEnabled(options.enabled, query) !== false && + resolveStaleTime(options.staleTime, query) !== 'static' + ) { const value = typeof field === 'function' ? field(query) : field return value === 'always' || (value !== false && isStale(query, options)) diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 735b8ea263..3bf6e6c55f 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -99,12 +99,16 @@ export type QueryFunction< TPageParam = never, > = (context: QueryFunctionContext) => T | Promise -export type StaleTime< +export type StaleTime = number | 'static' + +export type StaleTimeFunction< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, -> = number | ((query: Query) => number) +> = + | StaleTime + | ((query: Query) => StaleTime) export type Enabled< TQueryFnData = unknown, @@ -329,7 +333,7 @@ export interface QueryObserverOptions< * If set to a function, the function will be executed with the query to compute a `staleTime`. * Defaults to `0`. */ - staleTime?: StaleTime + staleTime?: StaleTimeFunction /** * If set to a number, the query will continuously refetch at this frequency in milliseconds. * If set to a function, the function will be executed with the latest data and query to compute a frequency @@ -502,7 +506,7 @@ export interface FetchQueryOptions< * The time in milliseconds after data is considered stale. * If the data is fresh it will be returned from the cache. */ - staleTime?: StaleTime + staleTime?: StaleTimeFunction } export interface EnsureQueryDataOptions< diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 8c2b50a089..b3e383d7cd 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -8,6 +8,7 @@ import type { QueryKey, QueryOptions, StaleTime, + StaleTimeFunction, } from './types' import type { Mutation } from './mutation' import type { FetchOptions, Query } from './query' @@ -102,9 +103,11 @@ export function resolveStaleTime< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - staleTime: undefined | StaleTime, + staleTime: + | undefined + | StaleTimeFunction, query: Query, -): number | undefined { +): StaleTime | undefined { return typeof staleTime === 'function' ? staleTime(query) : staleTime } diff --git a/packages/react-query/src/suspense.ts b/packages/react-query/src/suspense.ts index 6981e07422..b5bb6677e2 100644 --- a/packages/react-query/src/suspense.ts +++ b/packages/react-query/src/suspense.ts @@ -21,15 +21,18 @@ export const defaultThrowOnError = < export const ensureSuspenseTimers = ( defaultedOptions: DefaultedQueryObserverOptions, ) => { - const originalStaleTime = defaultedOptions.staleTime - if (defaultedOptions.suspense) { // Handle staleTime to ensure minimum 1000ms in Suspense mode // This prevents unnecessary refetching when components remount after suspending + + const clamp = (value: number | 'static' | undefined) => + value === 'static' ? value : Math.max(value ?? 1000, 1000) + + const originalStaleTime = defaultedOptions.staleTime defaultedOptions.staleTime = typeof originalStaleTime === 'function' - ? (...args) => Math.max(originalStaleTime(...args), 1000) - : Math.max(originalStaleTime ?? 1000, 1000) + ? (...args) => clamp(originalStaleTime(...args)) + : clamp(originalStaleTime) if (typeof defaultedOptions.gcTime === 'number') { defaultedOptions.gcTime = Math.max(defaultedOptions.gcTime, 1000)