Skip to content

Commit

Permalink
feat(react-query): allow useQuery and useQueries to unsubscribe from …
Browse files Browse the repository at this point in the history
…the query cache with an option (#8348)

* feat(react-query): allow useQuery and useQueries to unsubscribe from the query cache with an option

* test: subscribed

* fix: revert calling getOptimisticResult later

* docs(react): update the react-native.md section (#8506)

* update the doc

* update the doc

---------

Co-authored-by: Dominik Dorfmeister <[email protected]>

* docs: reference

---------

Co-authored-by: Florian De la comble <[email protected]>
  • Loading branch information
TkDodo and flodlc authored Jan 8, 2025
1 parent 3c5d8e3 commit e2bc216
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 98 deletions.
97 changes: 9 additions & 88 deletions docs/framework/react/react-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,108 +92,29 @@ export function useRefreshOnFocus<T>(refetch: () => Promise<T>) {

In the above code, `refetch` is skipped the first time because `useFocusEffect` calls our callback on mount in addition to screen focus.

## Disable re-renders on out of focus Screens

In some situations, including performance concerns, you may want to stop re-renders when a React Native screen gets out of focus. To achieve this we can use `useFocusEffect` from `@react-navigation/native` together with the `notifyOnChangeProps` query option.

This custom hook provides a `notifyOnChangeProps` option that will return an empty array whenever a screen goes out of focus - effectively stopping any re-renders on that scenario. Whenever the screens gets in focus again, the behavior goes back to normal.

```tsx
import React from 'react'
import { NotifyOnChangeProps } from '@tanstack/query-core'
import { useFocusEffect } from '@react-navigation/native'

export function useFocusNotifyOnChangeProps(
notifyOnChangeProps?: NotifyOnChangeProps,
) {
const focusedRef = React.useRef(true)

useFocusEffect(
React.useCallback(() => {
focusedRef.current = true

return () => {
focusedRef.current = false
}
}, []),
)

return () => {
if (!focusedRef.current) {
return []
}

if (typeof notifyOnChangeProps === 'function') {
return notifyOnChangeProps()
}

return notifyOnChangeProps
}
}
```

In the above code, `useFocusEffect` is used to change the value of a reference that the callback will use as a condition.
## Disable queries on out of focus screens

The argument is wrapped in a reference to also guarantee that the returned callback always keeps the same reference.
If you don’t want certain queries to remain “live” while a screen is out of focus, you can use the subscribed prop on useQuery. This prop lets you control whether a query stays subscribed to updates. Combined with React Navigation’s useIsFocused, it allows you to seamlessly unsubscribe from queries when a screen isn’t in focus:

Example usage:

```tsx
function MyComponent() {
const notifyOnChangeProps = useFocusNotifyOnChangeProps()

const { dataUpdatedAt } = useQuery({
queryKey: ['myKey'],
queryFn: async () => {
const response = await fetch(
'https://api.github.com/repos/tannerlinsley/react-query',
)
return response.json()
},
notifyOnChangeProps,
})

return <Text>DataUpdatedAt: {dataUpdatedAt}</Text>
}
```

## 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() {
const focusedRef = React.useRef(true)

useFocusEffect(
React.useCallback(() => {
focusedRef.current = true

return () => {
focusedRef.current = false
}
}, []),
)

return () => focusedRef.current
}
```

Example usage:
import { useIsFocused } from '@react-navigation/native'
import { useQuery } from '@tanstack/react-query'
import { Text } from 'react-native'

```tsx
function MyComponent() {
const isFocused = useQueryFocusAware()
const isFocused = useIsFocused()

const { dataUpdatedAt } = useQuery({
queryKey: ['key'],
queryFn: () => fetch(...),
enabled: isFocused,
subscribed: isFocused,
})

return <Text>DataUpdatedAt: {dataUpdatedAt}</Text>
}
```

When subscribed is false, the query unsubscribes from updates and won’t trigger re-renders or fetch new data for that screen. Once it becomes true again (e.g., when the screen regains focus), the query re-subscribes and stays up to date.
5 changes: 5 additions & 0 deletions docs/framework/react/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const {
select,
staleTime,
structuralSharing,
subscribed,
throwOnError,
},
queryClient,
Expand Down Expand Up @@ -161,6 +162,10 @@ const {
- Defaults to `true`
- If set to `false`, structural sharing between query results will be disabled.
- If set to a function, the old and new data values will be passed through this function, which should combine them into resolved data for the query. This way, you can retain references from the old data to improve performance even when that data contains non-serializable values.
- `subscribed: boolean`
- Optional
- Defaults to `true`
- If set to `false`, this instance of `useQuery` will not be subscribed to the cache. This means it won't trigger the `queryFn` on its own, and it won't receive updates if data gets into cache by other means.
- `throwOnError: undefined | boolean | (error: TError, query: Query) => boolean`
- Defaults to the global query config's `throwOnError` value, which is `undefined`
- Set this to `true` if you want errors to be thrown in the render phase and propagate to the nearest error boundary
Expand Down
104 changes: 104 additions & 0 deletions packages/react-query/src/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5964,6 +5964,110 @@ describe('useQuery', () => {
})
})

describe('subscribed', () => {
it('should be able to toggle subscribed', async () => {
const key = queryKey()
const queryFn = vi.fn(async () => 'data')
function Page() {
const [subscribed, setSubscribed] = React.useState(true)
const { data } = useQuery({
queryKey: key,
queryFn,
subscribed,
})
return (
<div>
<span>data: {data}</span>
<button onClick={() => setSubscribed(!subscribed)}>toggle</button>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)
await waitFor(() => rendered.getByText('data: data'))

expect(
queryClient.getQueryCache().find({ queryKey: key })!.observers.length,
).toBe(1)

fireEvent.click(rendered.getByRole('button', { name: 'toggle' }))

expect(
queryClient.getQueryCache().find({ queryKey: key })!.observers.length,
).toBe(0)

expect(queryFn).toHaveBeenCalledTimes(1)

fireEvent.click(rendered.getByRole('button', { name: 'toggle' }))

// background refetch when we re-subscribe
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2))
expect(
queryClient.getQueryCache().find({ queryKey: key })!.observers.length,
).toBe(1)
})

it('should not be attached to the query when subscribed is false', async () => {
const key = queryKey()
const queryFn = vi.fn(async () => 'data')
function Page() {
const { data } = useQuery({
queryKey: key,
queryFn,
subscribed: false,
})
return (
<div>
<span>data: {data}</span>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)
await waitFor(() => rendered.getByText('data:'))

expect(
queryClient.getQueryCache().find({ queryKey: key })!.observers.length,
).toBe(0)

expect(queryFn).toHaveBeenCalledTimes(0)
})

it('should not re-render when data is added to the cache when subscribed is false', async () => {
const key = queryKey()
let renders = 0
function Page() {
const { data } = useQuery({
queryKey: key,
queryFn: async () => 'data',
subscribed: false,
})
renders++
return (
<div>
<span>{data ? 'has data' + data : 'no data'}</span>
<button
onClick={() => queryClient.setQueryData<string>(key, 'new data')}
>
set data
</button>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)
await waitFor(() => rendered.getByText('no data'))

fireEvent.click(rendered.getByRole('button', { name: 'set data' }))

await sleep(10)

await waitFor(() => rendered.getByText('no data'))

expect(renders).toBe(1)
})
})

it('should have status=error on mount when a query has failed', async () => {
const key = queryKey()
const states: Array<UseQueryResult<unknown>> = []
Expand Down
8 changes: 7 additions & 1 deletion packages/react-query/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ export interface UseBaseQueryOptions<
TData,
TQueryData,
TQueryKey
> {}
> {
/**
* Set this to `false` to unsubscribe this observer from updates to the query cache.
* Defaults to `true`.
*/
subscribed?: boolean
}

export type AnyUseQueryOptions = UseQueryOptions<any, any, any, any>
export interface UseQueryOptions<
Expand Down
10 changes: 6 additions & 4 deletions packages/react-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,22 +82,24 @@ export function useBaseQuery<
),
)

// note: this must be called before useSyncExternalStore
const result = observer.getOptimisticResult(defaultedOptions)

const shouldSubscribe = !isRestoring && options.subscribed !== false
React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
const unsubscribe = isRestoring
? noop
: observer.subscribe(notifyManager.batchCalls(onStoreChange))
const unsubscribe = shouldSubscribe
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
: noop

// Update result to make sure we did not miss any query updates
// between creating the observer and subscribing to it.
observer.updateResult()

return unsubscribe
},
[observer, isRestoring],
[observer, shouldSubscribe],
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult(),
Expand Down
13 changes: 8 additions & 5 deletions packages/react-query/src/useQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ type UseQueryOptionsForUseQueries<
TQueryKey extends QueryKey = QueryKey,
> = OmitKeyof<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'placeholderData'
'placeholderData' | 'subscribed'
> & {
placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction<TQueryFnData>
}
Expand Down Expand Up @@ -231,6 +231,7 @@ export function useQueries<
}: {
queries: readonly [...QueriesOptions<T>]
combine?: (result: QueriesResults<T>) => TCombinedResult
subscribed?: boolean
},
queryClient?: QueryClient,
): TCombinedResult {
Expand Down Expand Up @@ -271,19 +272,21 @@ export function useQueries<
),
)

// note: this must be called before useSyncExternalStore
const [optimisticResult, getCombinedResult, trackResult] =
observer.getOptimisticResult(
defaultedQueries,
(options as QueriesObserverOptions<TCombinedResult>).combine,
)

const shouldSubscribe = !isRestoring && options.subscribed !== false
React.useSyncExternalStore(
React.useCallback(
(onStoreChange) =>
isRestoring
? noop
: observer.subscribe(notifyManager.batchCalls(onStoreChange)),
[observer, isRestoring],
shouldSubscribe
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
: noop,
[observer, shouldSubscribe],
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult(),
Expand Down

0 comments on commit e2bc216

Please sign in to comment.