From 170958ac3390b6a93850e46313388e6c3ac994f1 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 25 Nov 2024 11:30:15 +0100 Subject: [PATCH 1/5] feat(react-query): allow useQuery and useQueries to unsubscribe from the query cache with an option --- packages/react-query/src/types.ts | 8 +++++++- packages/react-query/src/useBaseQuery.ts | 12 +++++++----- packages/react-query/src/useQueries.ts | 23 +++++++++++++---------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/react-query/src/types.ts b/packages/react-query/src/types.ts index 8516536361..dd58a00af8 100644 --- a/packages/react-query/src/types.ts +++ b/packages/react-query/src/types.ts @@ -29,7 +29,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 interface UseQueryOptions< TQueryFnData = unknown, diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index bcbf700ef7..91b701094f 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -82,14 +82,14 @@ export function useBaseQuery< ), ) - 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. @@ -97,7 +97,7 @@ export function useBaseQuery< return unsubscribe }, - [observer, isRestoring], + [observer, shouldSubscribe], ), () => observer.getCurrentResult(), () => observer.getCurrentResult(), @@ -109,6 +109,8 @@ export function useBaseQuery< observer.setOptions(defaultedOptions, { listeners: false }) }, [defaultedOptions, observer]) + const result = observer.getOptimisticResult(defaultedOptions) + // Handle suspense if (shouldSuspend(defaultedOptions, result)) { throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 90ef2e32ad..4a77581521 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -47,7 +47,7 @@ type UseQueryOptionsForUseQueries< TQueryKey extends QueryKey = QueryKey, > = OmitKeyof< UseQueryOptions, - 'placeholderData' + 'placeholderData' | 'subscribed' > & { placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction } @@ -231,6 +231,7 @@ export function useQueries< }: { queries: readonly [...QueriesOptions] combine?: (result: QueriesResults) => TCombinedResult + subscribed?: boolean }, queryClient?: QueryClient, ): TCombinedResult { @@ -271,19 +272,15 @@ export function useQueries< ), ) - const [optimisticResult, getCombinedResult, trackResult] = - observer.getOptimisticResult( - defaultedQueries, - (options as QueriesObserverOptions).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(), @@ -301,6 +298,12 @@ export function useQueries< ) }, [defaultedQueries, options, observer]) + const [optimisticResult, getCombinedResult, trackResult] = + observer.getOptimisticResult( + defaultedQueries, + (options as QueriesObserverOptions).combine, + ) + const shouldAtLeastOneSuspend = optimisticResult.some((result, index) => shouldSuspend(defaultedQueries[index], result), ) From f5623bf06f1376cd639593f5565fd79875622c2b Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 25 Nov 2024 13:19:46 +0100 Subject: [PATCH 2/5] test: subscribed --- .../src/__tests__/useQuery.test.tsx | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index e336b99815..147825f6b7 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -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 ( +
+ data: {data} + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + 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 ( +
+ data: {data} +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + 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 ( +
+ {data ? 'has data' + data : 'no data'} + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + 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> = [] From b49c3883436dcadac40598898aae6abf233e2dca Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Mon, 25 Nov 2024 13:37:54 +0100 Subject: [PATCH 3/5] fix: revert calling getOptimisticResult later --- packages/react-query/src/useBaseQuery.ts | 6 +++--- packages/react-query/src/useQueries.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 91b701094f..1ee2f1cd07 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -82,8 +82,10 @@ export function useBaseQuery< ), ) - const shouldSubscribe = !isRestoring && options.subscribed !== false + // note: this must be called before useSyncExternalStore + const result = observer.getOptimisticResult(defaultedOptions) + const shouldSubscribe = !isRestoring && options.subscribed !== false React.useSyncExternalStore( React.useCallback( (onStoreChange) => { @@ -109,8 +111,6 @@ export function useBaseQuery< observer.setOptions(defaultedOptions, { listeners: false }) }, [defaultedOptions, observer]) - const result = observer.getOptimisticResult(defaultedOptions) - // Handle suspense if (shouldSuspend(defaultedOptions, result)) { throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 4a77581521..dd4ac9f96e 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -272,8 +272,14 @@ export function useQueries< ), ) - const shouldSubscribe = !isRestoring && options.subscribed !== false + // note: this must be called before useSyncExternalStore + const [optimisticResult, getCombinedResult, trackResult] = + observer.getOptimisticResult( + defaultedQueries, + (options as QueriesObserverOptions).combine, + ) + const shouldSubscribe = !isRestoring && options.subscribed !== false React.useSyncExternalStore( React.useCallback( (onStoreChange) => @@ -298,12 +304,6 @@ export function useQueries< ) }, [defaultedQueries, options, observer]) - const [optimisticResult, getCombinedResult, trackResult] = - observer.getOptimisticResult( - defaultedQueries, - (options as QueriesObserverOptions).combine, - ) - const shouldAtLeastOneSuspend = optimisticResult.some((result, index) => shouldSuspend(defaultedQueries[index], result), ) From 67ac48af7781a084f7b53c4818dc9eb9550d0bba Mon Sep 17 00:00:00 2001 From: Florian De la comble Date: Wed, 8 Jan 2025 14:17:29 +0100 Subject: [PATCH 4/5] docs(react): update the react-native.md section (#8506) * update the doc * update the doc --------- Co-authored-by: Dominik Dorfmeister --- docs/framework/react/react-native.md | 97 +++------------------------- 1 file changed, 9 insertions(+), 88 deletions(-) diff --git a/docs/framework/react/react-native.md b/docs/framework/react/react-native.md index f757015198..a8f71452bd 100644 --- a/docs/framework/react/react-native.md +++ b/docs/framework/react/react-native.md @@ -92,108 +92,29 @@ export function useRefreshOnFocus(refetch: () => Promise) { 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 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() { - 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 DataUpdatedAt: {dataUpdatedAt} } ``` + +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. From bab84e15f76ec4927a0657654f04c048fe8d7bd3 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Wed, 8 Jan 2025 14:31:35 +0100 Subject: [PATCH 5/5] docs: reference --- docs/framework/react/reference/useQuery.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index 92ce54f237..3032287d90 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -53,6 +53,7 @@ const { select, staleTime, structuralSharing, + subscribed, throwOnError, }, queryClient, @@ -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