diff --git a/docs/framework/react/guides/disabling-queries.md b/docs/framework/react/guides/disabling-queries.md
index 68930398093..da173f688bb 100644
--- a/docs/framework/react/guides/disabling-queries.md
+++ b/docs/framework/react/guides/disabling-queries.md
@@ -3,7 +3,7 @@ id: disabling-queries
title: Disabling/Pausing Queries
---
-If you ever want to disable a query from automatically running, you can use the `enabled = false` option.
+If you ever want to disable a query from automatically running, you can use the `enabled = false` option. The enabled option also accepts a callback that returns a boolean.
When `enabled` is `false`:
diff --git a/docs/framework/react/react-native.md b/docs/framework/react/react-native.md
index c14f4c30fdc..ad8d230c319 100644
--- a/docs/framework/react/react-native.md
+++ b/docs/framework/react/react-native.md
@@ -143,3 +143,34 @@ function MyComponent() {
return DataUpdatedAt: {dataUpdatedAt}
}
```
+
+## Disable queries on out of focus screens
+
+Enabled can also be set to a callback to support disabling queries on out of focus screens without state and re-rendering on navigation, similar to how notifyOnChangeProps works but in addition it wont trigger refetching when invalidating queries with refetchType active.
+
+```tsx
+import React from 'react'
+import { useFocusEffect } from '@react-navigation/native'
+
+export function useQueryFocusAware(notifyOnChangeProps?: NotifyOnChangeProps) {
+ const focusedRef = React.useRef(true)
+
+ useFocusEffect(
+ React.useCallback(() => {
+ focusedRef.current = true
+
+ return () => {
+ focusedRef.current = false
+ }
+ }, []),
+ )
+
+ return () => focusRef.current
+
+ useQuery({
+ queryKey: ['key'],
+ queryFn: () => fetch(...),
+ enabled: () => focusedRef.current,
+ })
+}
+```
diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md
index 02137a3cdce..c71ba342ae8 100644
--- a/docs/framework/react/reference/useQuery.md
+++ b/docs/framework/react/reference/useQuery.md
@@ -70,7 +70,7 @@ const {
- The function that the query will use to request data.
- Receives a [QueryFunctionContext](../../guides/query-functions#queryfunctioncontext)
- Must return a promise that will either resolve data or throw an error. The data cannot be `undefined`.
-- `enabled: boolean`
+- `enabled: boolean | (query: Query) => boolean`
- Set this to `false` to disable this query from automatically running.
- Can be used for [Dependent Queries](../../guides/dependent-queries).
- `networkMode: 'online' | 'always' | 'offlineFirst`
diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx
index bc6d4e8d137..c3a1ebb0377 100644
--- a/packages/query-core/src/__tests__/queryObserver.test.tsx
+++ b/packages/query-core/src/__tests__/queryObserver.test.tsx
@@ -52,6 +52,173 @@ describe('queryObserver', () => {
unsubscribe()
})
+ describe('enabled is a callback that initially returns false', () => {
+ let observer: QueryObserver>
+ let enabled: boolean
+ let count: number
+ let key: Array
+
+ beforeEach(() => {
+ key = queryKey()
+ count = 0
+ enabled = false
+
+ observer = new QueryObserver(queryClient, {
+ queryKey: key,
+ staleTime: Infinity,
+ enabled: () => enabled,
+ queryFn: async () => {
+ await sleep(10)
+ count++
+ return 'data'
+ },
+ })
+ })
+
+ test('should not fetch on mount', () => {
+ const unsubscribe = observer.subscribe(vi.fn())
+
+ // Has not fetched and is not fetching since its disabled
+ expect(count).toBe(0)
+ expect(observer.getCurrentResult()).toMatchObject({
+ status: 'pending',
+ fetchStatus: 'idle',
+ data: undefined,
+ })
+
+ unsubscribe()
+ })
+
+ test('should not be re-fetched when invalidated with refetchType: all', async () => {
+ const unsubscribe = observer.subscribe(vi.fn())
+
+ queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
+
+ //So we still expect it to not have fetched and not be fetching
+ expect(count).toBe(0)
+ expect(observer.getCurrentResult()).toMatchObject({
+ status: 'pending',
+ fetchStatus: 'idle',
+ data: undefined,
+ })
+ await waitFor(() => expect(count).toBe(0))
+
+ unsubscribe()
+ })
+
+ test('should still trigger a fetch when refetch is called', async () => {
+ const unsubscribe = observer.subscribe(vi.fn())
+
+ expect(enabled).toBe(false)
+
+ //Not the same with explicit refetch, this will override enabled and trigger a fetch anyway
+ observer.refetch()
+
+ expect(observer.getCurrentResult()).toMatchObject({
+ status: 'pending',
+ fetchStatus: 'fetching',
+ data: undefined,
+ })
+
+ await waitFor(() => expect(count).toBe(1))
+ expect(observer.getCurrentResult()).toMatchObject({
+ status: 'success',
+ fetchStatus: 'idle',
+ data: 'data',
+ })
+
+ unsubscribe()
+ })
+
+ test('should fetch if unsubscribed, then enabled returns true, and then re-subscribed', async () => {
+ let unsubscribe = observer.subscribe(vi.fn())
+ expect(observer.getCurrentResult()).toMatchObject({
+ status: 'pending',
+ fetchStatus: 'idle',
+ data: undefined,
+ })
+
+ unsubscribe()
+
+ enabled = true
+
+ unsubscribe = observer.subscribe(vi.fn())
+
+ expect(observer.getCurrentResult()).toMatchObject({
+ status: 'pending',
+ fetchStatus: 'fetching',
+ data: undefined,
+ })
+
+ await waitFor(() => expect(count).toBe(1))
+
+ unsubscribe()
+ })
+
+ test('should not be re-fetched if not subscribed to after enabled was toggled to true', async () => {
+ const unsubscribe = observer.subscribe(vi.fn())
+
+ // Toggle enabled
+ enabled = true
+
+ unsubscribe()
+
+ queryClient.invalidateQueries({ queryKey: key, refetchType: 'active' })
+
+ expect(observer.getCurrentResult()).toMatchObject({
+ status: 'pending',
+ fetchStatus: 'idle',
+ data: undefined,
+ })
+ expect(count).toBe(0)
+ })
+
+ test('should not be re-fetched if not subscribed to after enabled was toggled to true', async () => {
+ const unsubscribe = observer.subscribe(vi.fn())
+
+ // Toggle enabled
+ enabled = true
+
+ queryClient.invalidateQueries({ queryKey: key, refetchType: 'active' })
+
+ expect(observer.getCurrentResult()).toMatchObject({
+ status: 'pending',
+ fetchStatus: 'fetching',
+ data: undefined,
+ })
+ await waitFor(() => expect(count).toBe(1))
+
+ unsubscribe()
+ })
+
+ test('should handle that the enabled callback updates the return value', async () => {
+ const unsubscribe = observer.subscribe(vi.fn())
+
+ // Toggle enabled
+ enabled = true
+
+ queryClient.invalidateQueries({ queryKey: key, refetchType: 'inactive' })
+
+ //should not refetch since it was active and we only refetch inactive
+ await waitFor(() => expect(count).toBe(0))
+
+ queryClient.invalidateQueries({ queryKey: key, refetchType: 'active' })
+
+ //should refetch since it was active and we refetch active
+ await waitFor(() => expect(count).toBe(1))
+
+ // Toggle enabled
+ enabled = false
+
+ //should not refetch since it is not active and we only refetch active
+ queryClient.invalidateQueries({ queryKey: key, refetchType: 'active' })
+
+ await waitFor(() => expect(count).toBe(1))
+
+ unsubscribe()
+ })
+ })
+
test('should be able to read latest data when re-subscribing (but not re-fetching)', async () => {
const key = queryKey()
let count = 0
@@ -429,6 +596,20 @@ describe('queryObserver', () => {
expect(queryFn).toHaveBeenCalledTimes(0)
})
+ test('should not trigger a fetch when subscribed and disabled by callback', async () => {
+ const key = queryKey()
+ const queryFn = vi.fn, string>().mockReturnValue('data')
+ const observer = new QueryObserver(queryClient, {
+ queryKey: key,
+ queryFn,
+ enabled: () => false,
+ })
+ const unsubscribe = observer.subscribe(() => undefined)
+ await sleep(1)
+ unsubscribe()
+ expect(queryFn).toHaveBeenCalledTimes(0)
+ })
+
test('should not trigger a fetch when not subscribed', async () => {
const key = queryKey()
const queryFn = vi.fn, string>().mockReturnValue('data')
diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts
index 2ccb91e2011..fa2900d098e 100644
--- a/packages/query-core/src/query.ts
+++ b/packages/query-core/src/query.ts
@@ -1,4 +1,10 @@
-import { ensureQueryFn, noop, replaceData, timeUntilStale } from './utils'
+import {
+ ensureQueryFn,
+ noop,
+ replaceData,
+ resolveEnabled,
+ timeUntilStale,
+} from './utils'
import { notifyManager } from './notifyManager'
import { canFetch, createRetryer, isCancelledError } from './retryer'
import { Removable } from './removable'
@@ -244,7 +250,9 @@ export class Query<
}
isActive(): boolean {
- return this.observers.some((observer) => observer.options.enabled !== false)
+ return this.observers.some(
+ (observer) => resolveEnabled(observer.options.enabled, this) !== false,
+ )
}
isDisabled(): boolean {
diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts
index ef25d32e8d1..0a73184241a 100644
--- a/packages/query-core/src/queryObserver.ts
+++ b/packages/query-core/src/queryObserver.ts
@@ -3,6 +3,7 @@ import {
isValidTimeout,
noop,
replaceData,
+ resolveEnabled,
resolveStaleTime,
shallowEqualObjects,
timeUntilStale,
@@ -149,9 +150,14 @@ export class QueryObserver<
if (
this.options.enabled !== undefined &&
- typeof this.options.enabled !== 'boolean'
+ typeof this.options.enabled !== 'boolean' &&
+ typeof this.options.enabled !== 'function' &&
+ typeof resolveEnabled(this.options.enabled, this.#currentQuery) !==
+ 'boolean'
) {
- throw new Error('Expected enabled to be a boolean')
+ throw new Error(
+ 'Expected enabled to be a boolean or a callback that returns a boolean',
+ )
}
this.#updateQuery()
@@ -190,7 +196,8 @@ export class QueryObserver<
if (
mounted &&
(this.#currentQuery !== prevQuery ||
- this.options.enabled !== prevOptions.enabled ||
+ resolveEnabled(this.options.enabled, this.#currentQuery) !==
+ resolveEnabled(prevOptions.enabled, this.#currentQuery) ||
resolveStaleTime(this.options.staleTime, this.#currentQuery) !==
resolveStaleTime(prevOptions.staleTime, this.#currentQuery))
) {
@@ -203,7 +210,8 @@ export class QueryObserver<
if (
mounted &&
(this.#currentQuery !== prevQuery ||
- this.options.enabled !== prevOptions.enabled ||
+ resolveEnabled(this.options.enabled, this.#currentQuery) !==
+ resolveEnabled(prevOptions.enabled, this.#currentQuery) ||
nextRefetchInterval !== this.#currentRefetchInterval)
) {
this.#updateRefetchInterval(nextRefetchInterval)
@@ -377,7 +385,7 @@ export class QueryObserver<
if (
isServer ||
- this.options.enabled === false ||
+ resolveEnabled(this.options.enabled, this.#currentQuery) === false ||
!isValidTimeout(this.#currentRefetchInterval) ||
this.#currentRefetchInterval === 0
) {
@@ -692,7 +700,7 @@ function shouldLoadOnMount(
options: QueryObserverOptions,
): boolean {
return (
- options.enabled !== false &&
+ resolveEnabled(options.enabled, query) !== false &&
query.state.data === undefined &&
!(query.state.status === 'error' && options.retryOnMount === false)
)
@@ -716,7 +724,7 @@ function shouldFetchOn(
(typeof options)['refetchOnWindowFocus'] &
(typeof options)['refetchOnReconnect'],
) {
- if (options.enabled !== false) {
+ if (resolveEnabled(options.enabled, query) !== false) {
const value = typeof field === 'function' ? field(query) : field
return value === 'always' || (value !== false && isStale(query, options))
@@ -731,7 +739,8 @@ function shouldFetchOptionally(
prevOptions: QueryObserverOptions,
): boolean {
return (
- (query !== prevQuery || prevOptions.enabled === false) &&
+ (query !== prevQuery ||
+ resolveEnabled(prevOptions.enabled, query) === false) &&
(!options.suspense || query.state.status !== 'error') &&
isStale(query, options)
)
@@ -742,7 +751,7 @@ function isStale(
options: QueryObserverOptions,
): boolean {
return (
- options.enabled !== false &&
+ resolveEnabled(options.enabled, query) !== false &&
query.isStaleByTime(resolveStaleTime(options.staleTime, query))
)
}
diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts
index 7b812a061f7..75a130fd7e4 100644
--- a/packages/query-core/src/types.ts
+++ b/packages/query-core/src/types.ts
@@ -54,6 +54,15 @@ export type StaleTime<
TQueryKey extends QueryKey = QueryKey,
> = number | ((query: Query) => number)
+export type Enabled<
+ TQueryFnData = unknown,
+ TError = DefaultError,
+ TData = TQueryFnData,
+ TQueryKey extends QueryKey = QueryKey,
+> =
+ | boolean
+ | ((query: Query) => boolean)
+
export type QueryPersister<
T = unknown,
TQueryKey extends QueryKey = QueryKey,
@@ -253,11 +262,12 @@ export interface QueryObserverOptions<
'queryKey'
> {
/**
- * Set this to `false` to disable automatic refetching when the query mounts or changes query keys.
+ * Set this to `false` or a function that returns `false` to disable automatic refetching when the query mounts or changes query keys.
* To refetch the query, use the `refetch` method returned from the `useQuery` instance.
+ * Accepts a boolean or function that returns a boolean.
* Defaults to `true`.
*/
- enabled?: boolean
+ enabled?: Enabled
/**
* The time in milliseconds after data is considered stale.
* If set to `Infinity`, the data will never be considered stale.
diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts
index 3cb9374e584..8b498ae1e4b 100644
--- a/packages/query-core/src/utils.ts
+++ b/packages/query-core/src/utils.ts
@@ -1,5 +1,6 @@
import type {
DefaultError,
+ Enabled,
FetchStatus,
MutationKey,
MutationStatus,
@@ -100,6 +101,18 @@ export function resolveStaleTime<
return typeof staleTime === 'function' ? staleTime(query) : staleTime
}
+export function resolveEnabled<
+ TQueryFnData = unknown,
+ TError = DefaultError,
+ TData = TQueryFnData,
+ TQueryKey extends QueryKey = QueryKey,
+>(
+ enabled: undefined | Enabled,
+ query: Query,
+): boolean | undefined {
+ return typeof enabled === 'function' ? enabled(query) : enabled
+}
+
export function matchQuery(
filters: QueryFilters,
query: Query,