Skip to content
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