Skip to content

feat(core): staleTime: 'static' #9139

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
May 29, 2025
Merged
11 changes: 9 additions & 2 deletions docs/framework/react/guides/important-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
11 changes: 6 additions & 5 deletions docs/framework/react/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/QueryClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions packages/query-core/src/__tests__/queryClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
43 changes: 43 additions & 0 deletions packages/query-core/src/__tests__/queryObserver.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueryObserverResult<unknown>> = []
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<QueryObserverResult> = []
const key = queryKey()
Expand Down Expand Up @@ -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, {
Expand Down
40 changes: 30 additions & 10 deletions packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
noop,
replaceData,
resolveEnabled,
resolveStaleTime,
skipToken,
timeUntilStale,
} from './utils'
Expand All @@ -24,6 +25,7 @@ import type {
QueryOptions,
QueryStatus,
SetDataOptions,
StaleTime,
} from './types'
import type { QueryObserver } from './queryObserver'
import type { Retryer } from './retryer'
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/query-core/src/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion packages/query-core/src/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
12 changes: 8 additions & 4 deletions packages/query-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,16 @@ export type QueryFunction<
TPageParam = never,
> = (context: QueryFunctionContext<TQueryKey, TPageParam>) => T | Promise<T>

export type StaleTime<
export type StaleTime = number | 'static'

export type StaleTimeFunction<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> = number | ((query: Query<TQueryFnData, TError, TData, TQueryKey>) => number)
> =
| StaleTime
| ((query: Query<TQueryFnData, TError, TData, TQueryKey>) => StaleTime)

export type Enabled<
TQueryFnData = unknown,
Expand Down Expand Up @@ -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<TQueryFnData, TError, TQueryData, TQueryKey>
staleTime?: StaleTimeFunction<TQueryFnData, TError, TQueryData, TQueryKey>
/**
* 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
Expand Down Expand Up @@ -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<TQueryFnData, TError, TData, TQueryKey>
staleTime?: StaleTimeFunction<TQueryFnData, TError, TData, TQueryKey>
}

export interface EnsureQueryDataOptions<
Expand Down
7 changes: 5 additions & 2 deletions packages/query-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
QueryKey,
QueryOptions,
StaleTime,
StaleTimeFunction,
} from './types'
import type { Mutation } from './mutation'
import type { FetchOptions, Query } from './query'
Expand Down Expand Up @@ -102,9 +103,11 @@ export function resolveStaleTime<
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
staleTime: undefined | StaleTime<TQueryFnData, TError, TData, TQueryKey>,
staleTime:
| undefined
| StaleTimeFunction<TQueryFnData, TError, TData, TQueryKey>,
query: Query<TQueryFnData, TError, TData, TQueryKey>,
): number | undefined {
): StaleTime | undefined {
return typeof staleTime === 'function' ? staleTime(query) : staleTime
}

Expand Down
Loading