Skip to content

Commit e2bc216

Browse files
TkDodoflodlc
andauthoredJan 8, 2025··
feat(react-query): allow useQuery and useQueries to unsubscribe from 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 <office@dorfmeister.cc> * docs: reference --------- Co-authored-by: Florian De la comble <f.delacomble@gmail.com>
1 parent 3c5d8e3 commit e2bc216

File tree

6 files changed

+139
-98
lines changed

6 files changed

+139
-98
lines changed
 

Diff for: ‎docs/framework/react/react-native.md

+9-88
Original file line numberDiff line numberDiff line change
@@ -92,108 +92,29 @@ export function useRefreshOnFocus<T>(refetch: () => Promise<T>) {
9292

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

95-
## Disable re-renders on out of focus Screens
96-
97-
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.
98-
99-
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.
100-
101-
```tsx
102-
import React from 'react'
103-
import { NotifyOnChangeProps } from '@tanstack/query-core'
104-
import { useFocusEffect } from '@react-navigation/native'
105-
106-
export function useFocusNotifyOnChangeProps(
107-
notifyOnChangeProps?: NotifyOnChangeProps,
108-
) {
109-
const focusedRef = React.useRef(true)
110-
111-
useFocusEffect(
112-
React.useCallback(() => {
113-
focusedRef.current = true
114-
115-
return () => {
116-
focusedRef.current = false
117-
}
118-
}, []),
119-
)
120-
121-
return () => {
122-
if (!focusedRef.current) {
123-
return []
124-
}
125-
126-
if (typeof notifyOnChangeProps === 'function') {
127-
return notifyOnChangeProps()
128-
}
129-
130-
return notifyOnChangeProps
131-
}
132-
}
133-
```
134-
135-
In the above code, `useFocusEffect` is used to change the value of a reference that the callback will use as a condition.
95+
## Disable queries on out of focus screens
13696

137-
The argument is wrapped in a reference to also guarantee that the returned callback always keeps the same reference.
97+
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:
13898

13999
Example usage:
140100

141-
```tsx
142-
function MyComponent() {
143-
const notifyOnChangeProps = useFocusNotifyOnChangeProps()
144-
145-
const { dataUpdatedAt } = useQuery({
146-
queryKey: ['myKey'],
147-
queryFn: async () => {
148-
const response = await fetch(
149-
'https://api.github.com/repos/tannerlinsley/react-query',
150-
)
151-
return response.json()
152-
},
153-
notifyOnChangeProps,
154-
})
155-
156-
return <Text>DataUpdatedAt: {dataUpdatedAt}</Text>
157-
}
158-
```
159-
160-
## Disable queries on out of focus screens
161-
162-
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.
163-
164101
```tsx
165102
import React from 'react'
166-
import { useFocusEffect } from '@react-navigation/native'
167-
168-
export function useQueryFocusAware() {
169-
const focusedRef = React.useRef(true)
170-
171-
useFocusEffect(
172-
React.useCallback(() => {
173-
focusedRef.current = true
174-
175-
return () => {
176-
focusedRef.current = false
177-
}
178-
}, []),
179-
)
180-
181-
return () => focusedRef.current
182-
}
183-
```
184-
185-
Example usage:
103+
import { useIsFocused } from '@react-navigation/native'
104+
import { useQuery } from '@tanstack/react-query'
105+
import { Text } from 'react-native'
186106

187-
```tsx
188107
function MyComponent() {
189-
const isFocused = useQueryFocusAware()
108+
const isFocused = useIsFocused()
190109

191110
const { dataUpdatedAt } = useQuery({
192111
queryKey: ['key'],
193112
queryFn: () => fetch(...),
194-
enabled: isFocused,
113+
subscribed: isFocused,
195114
})
196115

197116
return <Text>DataUpdatedAt: {dataUpdatedAt}</Text>
198117
}
199118
```
119+
120+
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.

Diff for: ‎docs/framework/react/reference/useQuery.md

+5
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const {
5353
select,
5454
staleTime,
5555
structuralSharing,
56+
subscribed,
5657
throwOnError,
5758
},
5859
queryClient,
@@ -161,6 +162,10 @@ const {
161162
- Defaults to `true`
162163
- If set to `false`, structural sharing between query results will be disabled.
163164
- 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.
165+
- `subscribed: boolean`
166+
- Optional
167+
- Defaults to `true`
168+
- 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.
164169
- `throwOnError: undefined | boolean | (error: TError, query: Query) => boolean`
165170
- Defaults to the global query config's `throwOnError` value, which is `undefined`
166171
- Set this to `true` if you want errors to be thrown in the render phase and propagate to the nearest error boundary

Diff for: ‎packages/react-query/src/__tests__/useQuery.test.tsx

+104
Original file line numberDiff line numberDiff line change
@@ -5964,6 +5964,110 @@ describe('useQuery', () => {
59645964
})
59655965
})
59665966

5967+
describe('subscribed', () => {
5968+
it('should be able to toggle subscribed', async () => {
5969+
const key = queryKey()
5970+
const queryFn = vi.fn(async () => 'data')
5971+
function Page() {
5972+
const [subscribed, setSubscribed] = React.useState(true)
5973+
const { data } = useQuery({
5974+
queryKey: key,
5975+
queryFn,
5976+
subscribed,
5977+
})
5978+
return (
5979+
<div>
5980+
<span>data: {data}</span>
5981+
<button onClick={() => setSubscribed(!subscribed)}>toggle</button>
5982+
</div>
5983+
)
5984+
}
5985+
5986+
const rendered = renderWithClient(queryClient, <Page />)
5987+
await waitFor(() => rendered.getByText('data: data'))
5988+
5989+
expect(
5990+
queryClient.getQueryCache().find({ queryKey: key })!.observers.length,
5991+
).toBe(1)
5992+
5993+
fireEvent.click(rendered.getByRole('button', { name: 'toggle' }))
5994+
5995+
expect(
5996+
queryClient.getQueryCache().find({ queryKey: key })!.observers.length,
5997+
).toBe(0)
5998+
5999+
expect(queryFn).toHaveBeenCalledTimes(1)
6000+
6001+
fireEvent.click(rendered.getByRole('button', { name: 'toggle' }))
6002+
6003+
// background refetch when we re-subscribe
6004+
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2))
6005+
expect(
6006+
queryClient.getQueryCache().find({ queryKey: key })!.observers.length,
6007+
).toBe(1)
6008+
})
6009+
6010+
it('should not be attached to the query when subscribed is false', async () => {
6011+
const key = queryKey()
6012+
const queryFn = vi.fn(async () => 'data')
6013+
function Page() {
6014+
const { data } = useQuery({
6015+
queryKey: key,
6016+
queryFn,
6017+
subscribed: false,
6018+
})
6019+
return (
6020+
<div>
6021+
<span>data: {data}</span>
6022+
</div>
6023+
)
6024+
}
6025+
6026+
const rendered = renderWithClient(queryClient, <Page />)
6027+
await waitFor(() => rendered.getByText('data:'))
6028+
6029+
expect(
6030+
queryClient.getQueryCache().find({ queryKey: key })!.observers.length,
6031+
).toBe(0)
6032+
6033+
expect(queryFn).toHaveBeenCalledTimes(0)
6034+
})
6035+
6036+
it('should not re-render when data is added to the cache when subscribed is false', async () => {
6037+
const key = queryKey()
6038+
let renders = 0
6039+
function Page() {
6040+
const { data } = useQuery({
6041+
queryKey: key,
6042+
queryFn: async () => 'data',
6043+
subscribed: false,
6044+
})
6045+
renders++
6046+
return (
6047+
<div>
6048+
<span>{data ? 'has data' + data : 'no data'}</span>
6049+
<button
6050+
onClick={() => queryClient.setQueryData<string>(key, 'new data')}
6051+
>
6052+
set data
6053+
</button>
6054+
</div>
6055+
)
6056+
}
6057+
6058+
const rendered = renderWithClient(queryClient, <Page />)
6059+
await waitFor(() => rendered.getByText('no data'))
6060+
6061+
fireEvent.click(rendered.getByRole('button', { name: 'set data' }))
6062+
6063+
await sleep(10)
6064+
6065+
await waitFor(() => rendered.getByText('no data'))
6066+
6067+
expect(renders).toBe(1)
6068+
})
6069+
})
6070+
59676071
it('should have status=error on mount when a query has failed', async () => {
59686072
const key = queryKey()
59696073
const states: Array<UseQueryResult<unknown>> = []

Diff for: ‎packages/react-query/src/types.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ export interface UseBaseQueryOptions<
3636
TData,
3737
TQueryData,
3838
TQueryKey
39-
> {}
39+
> {
40+
/**
41+
* Set this to `false` to unsubscribe this observer from updates to the query cache.
42+
* Defaults to `true`.
43+
*/
44+
subscribed?: boolean
45+
}
4046

4147
export type AnyUseQueryOptions = UseQueryOptions<any, any, any, any>
4248
export interface UseQueryOptions<

Diff for: ‎packages/react-query/src/useBaseQuery.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -82,22 +82,24 @@ export function useBaseQuery<
8282
),
8383
)
8484

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

88+
const shouldSubscribe = !isRestoring && options.subscribed !== false
8789
React.useSyncExternalStore(
8890
React.useCallback(
8991
(onStoreChange) => {
90-
const unsubscribe = isRestoring
91-
? noop
92-
: observer.subscribe(notifyManager.batchCalls(onStoreChange))
92+
const unsubscribe = shouldSubscribe
93+
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
94+
: noop
9395

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

98100
return unsubscribe
99101
},
100-
[observer, isRestoring],
102+
[observer, shouldSubscribe],
101103
),
102104
() => observer.getCurrentResult(),
103105
() => observer.getCurrentResult(),

Diff for: ‎packages/react-query/src/useQueries.ts

+8-5
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ type UseQueryOptionsForUseQueries<
4747
TQueryKey extends QueryKey = QueryKey,
4848
> = OmitKeyof<
4949
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
50-
'placeholderData'
50+
'placeholderData' | 'subscribed'
5151
> & {
5252
placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction<TQueryFnData>
5353
}
@@ -231,6 +231,7 @@ export function useQueries<
231231
}: {
232232
queries: readonly [...QueriesOptions<T>]
233233
combine?: (result: QueriesResults<T>) => TCombinedResult
234+
subscribed?: boolean
234235
},
235236
queryClient?: QueryClient,
236237
): TCombinedResult {
@@ -271,19 +272,21 @@ export function useQueries<
271272
),
272273
)
273274

275+
// note: this must be called before useSyncExternalStore
274276
const [optimisticResult, getCombinedResult, trackResult] =
275277
observer.getOptimisticResult(
276278
defaultedQueries,
277279
(options as QueriesObserverOptions<TCombinedResult>).combine,
278280
)
279281

282+
const shouldSubscribe = !isRestoring && options.subscribed !== false
280283
React.useSyncExternalStore(
281284
React.useCallback(
282285
(onStoreChange) =>
283-
isRestoring
284-
? noop
285-
: observer.subscribe(notifyManager.batchCalls(onStoreChange)),
286-
[observer, isRestoring],
286+
shouldSubscribe
287+
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
288+
: noop,
289+
[observer, shouldSubscribe],
287290
),
288291
() => observer.getCurrentResult(),
289292
() => observer.getCurrentResult(),

0 commit comments

Comments
 (0)
Please sign in to comment.