Skip to content

Commit c116834

Browse files
committed
feat(react-query): add pause provider
1 parent 8c03bd0 commit c116834

18 files changed

+377
-9
lines changed

docs/framework/react/react-native.md

+29
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ In the above code, `refetch` is skipped the first time because `useFocusEffect`
9595

9696
## Disable queries on out of focus screens
9797

98+
### `subscribed` option
99+
98100
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:
99101

100102
Example usage:
@@ -119,3 +121,30 @@ function MyComponent() {
119121
```
120122

121123
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.
124+
125+
### `PauseManagerProvider` option
126+
127+
In case you want to disable updates to _all_ queries in an out of focus screen, one alternative is to control them via `PauseManager`:
128+
129+
```tsx
130+
import React from 'react'
131+
import { useIsFocused } from '@react-navigation/native'
132+
import { PauseManager, PauseManagerProvider } from 'react-native'
133+
134+
function MyScreen() {
135+
const isFocused = useIsFocused()
136+
const pauseManager = useRef<PauseManager>(null)
137+
if (pauseManager.current === null) {
138+
pauseManager.current = new PauseManager(!isFocused)
139+
}
140+
useEffect(() => {
141+
pauseManager.current?.setPaused(!isFocused)
142+
}, [isFocused])
143+
144+
return (
145+
<PauseManagerProvider pauseManager={pauseManager}>
146+
<MyComponent />
147+
</PauseManagerProvider>
148+
)
149+
}
150+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
id: PauseManagerProvider
3+
title: PauseManagerProvider
4+
---
5+
6+
Use the `PauseManagerProvider` component to connect and provide a `PauseManager` to your application which is used to selectively disable updates:
7+
8+
```tsx
9+
import { PauseManager, PauseManagerProvider } from '@tanstack/react-query'
10+
11+
const pauseManager = new PauseManager()
12+
13+
function App() {
14+
return <PauseManagerProvider client={pauseManager}>...</PauseManagerProvider>
15+
}
16+
```
17+
18+
**Options**
19+
20+
- `pauseManager: PauseManager`
21+
- **Required**
22+
- the PauseManager instance to provide

examples/react/pause/.eslintrc

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": ["plugin:react/jsx-runtime", "plugin:react-hooks/recommended"]
3+
}

examples/react/pause/.gitignore

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
8+
# testing
9+
/coverage
10+
11+
# production
12+
/build
13+
14+
pnpm-lock.yaml
15+
yarn.lock
16+
package-lock.json
17+
18+
# misc
19+
.DS_Store
20+
.env.local
21+
.env.development.local
22+
.env.test.local
23+
.env.production.local
24+
25+
npm-debug.log*
26+
yarn-debug.log*
27+
yarn-error.log*

examples/react/pause/README.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Example
2+
3+
To run this example:
4+
5+
- `npm install`
6+
- `npm run dev`

examples/react/pause/index.html

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<link rel="icon" type="image/svg+xml" href="/emblem-light.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7+
<meta name="theme-color" content="#000000" />
8+
9+
<title>TanStack Query React Pause Example App</title>
10+
</head>
11+
<body>
12+
<noscript>You need to enable JavaScript to run this app.</noscript>
13+
<div id="root"></div>
14+
<script type="module" src="/src/index.tsx"></script>
15+
</body>
16+
</html>

examples/react/pause/package.json

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@tanstack/query-example-react-pause",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "vite",
7+
"build": "vite build",
8+
"preview": "vite preview"
9+
},
10+
"dependencies": {
11+
"@tanstack/react-query": "^5.67.1",
12+
"@tanstack/react-query-devtools": "^5.67.1",
13+
"react": "^19.0.0",
14+
"react-dom": "^19.0.0"
15+
},
16+
"devDependencies": {
17+
"@vitejs/plugin-react": "^4.3.3",
18+
"typescript": "5.8.2",
19+
"vite": "^5.3.5"
20+
}
21+
}
Loading

examples/react/pause/src/index.tsx

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import ReactDOM from 'react-dom/client'
2+
import {
3+
PauseManager,
4+
PauseManagerProvider,
5+
QueryClient,
6+
QueryClientProvider,
7+
useQuery,
8+
} from '@tanstack/react-query'
9+
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
10+
import { memo, useRef, useSyncExternalStore } from 'react'
11+
12+
const queryClient = new QueryClient()
13+
const pauseManager = new PauseManager(true)
14+
15+
let counter = 1
16+
17+
const useCounter = () =>
18+
useQuery({
19+
queryKey: ['counter'],
20+
refetchOnMount: false,
21+
queryFn: () => counter++,
22+
})
23+
24+
export default function App() {
25+
return (
26+
<QueryClientProvider client={queryClient}>
27+
<ReactQueryDevtools />
28+
<Example />
29+
</QueryClientProvider>
30+
)
31+
}
32+
33+
function Example() {
34+
const { data, refetch } = useCounter()
35+
const isPaused = useSyncExternalStore(
36+
(onStoreChange) => pauseManager.subscribe(onStoreChange),
37+
() => pauseManager.isPaused(),
38+
)
39+
40+
return (
41+
<div>
42+
<p>Parent Counter: {data}</p>
43+
<div style={{ opacity: isPaused ? 0.5 : 1 }}>
44+
<PauseManagerProvider pauseManager={pauseManager}>
45+
<MemoisedChild />
46+
</PauseManagerProvider>
47+
</div>
48+
<button onClick={() => refetch()}>Increment</button>
49+
<button onClick={() => pauseManager.setPaused(!isPaused)}>
50+
{isPaused ? 'Resume' : 'Pause'}
51+
</button>
52+
</div>
53+
)
54+
}
55+
56+
const MemoisedChild = memo(() => {
57+
const { data } = useCounter()
58+
const renders = useRef(0)
59+
renders.current++
60+
61+
return (
62+
<>
63+
<p>Child counter: {data}</p>
64+
<p>Child renders: {renders.current}</p>
65+
</>
66+
)
67+
})
68+
69+
const rootElement = document.getElementById('root') as HTMLElement
70+
ReactDOM.createRoot(rootElement).render(<App />)

examples/react/pause/tsconfig.json

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"useDefineForClassFields": true,
5+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
6+
"module": "ESNext",
7+
"skipLibCheck": true,
8+
9+
/* Bundler mode */
10+
"moduleResolution": "Bundler",
11+
"allowImportingTsExtensions": true,
12+
"resolveJsonModule": true,
13+
"isolatedModules": true,
14+
"noEmit": true,
15+
"jsx": "react-jsx",
16+
17+
/* Linting */
18+
"strict": true,
19+
"noUnusedLocals": true,
20+
"noUnusedParameters": true,
21+
"noFallthroughCasesInSwitch": true
22+
},
23+
"include": ["src", "eslint.config.js"]
24+
}

examples/react/pause/vite.config.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineConfig } from 'vite'
2+
import react from '@vitejs/plugin-react'
3+
4+
export default defineConfig({
5+
plugins: [react()],
6+
})

packages/query-core/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { MutationObserver } from './mutationObserver'
1313
export { notifyManager } from './notifyManager'
1414
export { focusManager } from './focusManager'
1515
export { onlineManager } from './onlineManager'
16+
export { PauseManager } from './pauseManager'
1617
export {
1718
hashKey,
1819
replaceEqualDeep,
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Subscribable } from './subscribable'
2+
3+
type Listener = (paused: boolean) => void
4+
5+
export class PauseManager extends Subscribable<Listener> {
6+
#paused: boolean
7+
8+
constructor(paused = false) {
9+
super()
10+
this.#paused = paused
11+
}
12+
13+
#onChange(): void {
14+
const isPaused = this.isPaused()
15+
this.listeners.forEach((listener) => {
16+
listener(isPaused)
17+
})
18+
}
19+
20+
setPaused(paused: boolean): void {
21+
const changed = this.#paused !== paused
22+
if (changed) {
23+
this.#paused = paused
24+
this.#onChange()
25+
}
26+
}
27+
28+
isPaused(): boolean {
29+
return this.#paused
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use client'
2+
import * as React from 'react'
3+
4+
import type { PauseManager } from '@tanstack/query-core'
5+
6+
export const PauseManagerContext = React.createContext<
7+
PauseManager | undefined
8+
>(undefined)
9+
10+
export const usePauseManager = () => {
11+
return React.useContext(PauseManagerContext)
12+
}
13+
14+
export type PauseManagerProviderProps = {
15+
pauseManager: PauseManager
16+
children?: React.ReactNode
17+
}
18+
19+
export const PauseManagerProvider = ({
20+
pauseManager,
21+
children,
22+
}: PauseManagerProviderProps): React.JSX.Element => {
23+
return (
24+
<PauseManagerContext.Provider value={pauseManager}>
25+
{children}
26+
</PauseManagerContext.Provider>
27+
)
28+
}

packages/react-query/src/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export type {
2929
UndefinedInitialDataInfiniteOptions,
3030
UnusedSkipTokenInfiniteOptions,
3131
} from './infiniteQueryOptions'
32+
export {
33+
PauseManagerContext,
34+
PauseManagerProvider,
35+
usePauseManager,
36+
} from './PauseManagerProvider'
3237
export {
3338
QueryClientContext,
3439
QueryClientProvider,

packages/react-query/src/useBaseQuery.ts

+26-4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
willFetch,
1818
} from './suspense'
1919
import { noop } from './utils'
20+
import { usePauseManager } from './PauseManagerProvider'
2021
import type {
2122
QueryClient,
2223
QueryKey,
@@ -53,6 +54,7 @@ export function useBaseQuery<
5354
const client = useQueryClient(queryClient)
5455
const isRestoring = useIsRestoring()
5556
const errorResetBoundary = useQueryErrorResetBoundary()
57+
const pauseManager = usePauseManager()
5658
const defaultedOptions = client.defaultQueryOptions(options)
5759

5860
;(client.getDefaultOptions().queries as any)?._experimental_beforeQuery?.(
@@ -97,17 +99,37 @@ export function useBaseQuery<
9799
React.useSyncExternalStore(
98100
React.useCallback(
99101
(onStoreChange) => {
100-
const unsubscribe = shouldSubscribe
101-
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
102-
: noop
102+
if (!shouldSubscribe) {
103+
return noop
104+
}
105+
106+
const subscribe = () =>
107+
observer.subscribe(notifyManager.batchCalls(onStoreChange))
108+
let unsubscribe: ReturnType<typeof subscribe>
109+
110+
if (pauseManager) {
111+
let unsubscribeObserver: (() => void) | undefined
112+
const onPausedChange = (paused: boolean) => {
113+
unsubscribeObserver?.()
114+
unsubscribeObserver = paused ? undefined : subscribe()
115+
}
116+
const unsubscribePaused = pauseManager.subscribe(onPausedChange)
117+
onPausedChange(pauseManager.isPaused())
118+
unsubscribe = () => {
119+
unsubscribePaused()
120+
unsubscribeObserver?.()
121+
}
122+
} else {
123+
unsubscribe = subscribe()
124+
}
103125

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

108130
return unsubscribe
109131
},
110-
[observer, shouldSubscribe],
132+
[observer, shouldSubscribe, pauseManager],
111133
),
112134
() => observer.getCurrentResult(),
113135
() => observer.getCurrentResult(),

0 commit comments

Comments
 (0)