Skip to content

Commit e7a3207

Browse files
authored
feat(useQuery): Provide AbortSignal to queryFn (TanStack#2782)
1 parent f21d932 commit e7a3207

File tree

9 files changed

+434
-65
lines changed

9 files changed

+434
-65
lines changed

docs/src/pages/guides/query-cancellation.md

Lines changed: 108 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,55 @@ id: query-cancellation
33
title: Query Cancellation
44
---
55

6-
By default, queries that unmount or become unused before their promises are resolved are simply ignored instead of canceled. Why is this?
6+
[_Previous method requiring a `cancel` function_](#old-cancel-function)
77

8-
- For most applications, ignoring out-of-date queries is sufficient.
9-
- Cancellation APIs may not be available for every query function.
10-
- If cancellation APIs are available, they typically vary in implementation between utilities/libraries (eg. Fetch vs Axios vs XMLHttpRequest).
8+
React Query provides each query function with an [`AbortSignal` instance](https://developer.mozilla.org/docs/Web/API/AbortSignal) **if it's available in your runtime environment**. When a query becomes out-of-date or inactive, this `signal` will become aborted. This means that all queries are cancellable and you can respond to the cancellation inside your query function if desired. The best part about this is that it allow you to continue to use normal async/await syntax while getting all the benefits of automatic cancellation. Additionally, this solution works better with TypeScript than the old solution.
119

12-
But don't worry! If your queries are high-bandwidth or potentially very expensive to download, React Query exposes a generic way to **cancel** query requests using a cancellation token or other related API. To integrate with this feature, attach a `cancel` function to the promise returned by your query that implements your request cancellation. When a query becomes out-of-date or inactive, this `promise.cancel` function will be called (if available):
10+
The `AbortController` API is available in [most runtime environments](https://developer.mozilla.org/docs/Web/API/AbortController#browser_compatibility), but if the runtime environment does not support it then the query function will receive `undefined` in its place. You may choose to polyfill the `AbortController` API if you wish, there are [several available](https://www.npmjs.com/search?q=abortcontroller%20polyfill).
11+
12+
## Using `fetch`
13+
14+
```js
15+
const query = useQuery('todos', async ({ signal }) => {
16+
const todosResponse = await fetch('/todos', {
17+
// Pass the signal to one fetch
18+
signal,
19+
})
20+
const todos = await todosResponse.json()
21+
22+
const todoDetails = todos.map(async ({ details } => {
23+
const response = await fetch(details, {
24+
// Or pass it to several
25+
signal,
26+
})
27+
return response.json()
28+
})
29+
30+
return Promise.all(todoDetails)
31+
})
32+
```
1333
1434
## Using `axios`
1535
36+
### Using `axios` [v0.22.0+](https://github.com/axios/axios/releases/tag/v0.22.0)
37+
1638
```js
1739
import axios from 'axios'
1840

19-
const query = useQuery('todos', () => {
41+
const query = useQuery('todos', ({ signal }) =>
42+
axios.get('/todos', {
43+
// Pass the signal to `axios`
44+
signal,
45+
})
46+
)
47+
```
48+
49+
### Using an `axios` version less than v0.22.0
50+
51+
```js
52+
import axios from 'axios'
53+
54+
const query = useQuery('todos', ({ signal }) => {
2055
// Create a new CancelToken source for this request
2156
const CancelToken = axios.CancelToken
2257
const source = CancelToken.source()
@@ -26,50 +61,98 @@ const query = useQuery('todos', () => {
2661
cancelToken: source.token,
2762
})
2863

29-
// Cancel the request if React Query calls the `promise.cancel` method
30-
promise.cancel = () => {
64+
// Cancel the request if React Query signals to abort
65+
signal?.addEventListener('abort', () => {
3166
source.cancel('Query was cancelled by React Query')
32-
}
67+
})
3368

3469
return promise
3570
})
3671
```
3772
38-
## Using `fetch`
73+
## Using `XMLHttpRequest`
74+
75+
```js
76+
const query = useQuery('todos', ({ signal }) => {
77+
return new Promise((resolve, reject) => {
78+
var oReq = new XMLHttpRequest()
79+
oReq.addEventListener('load', () => {
80+
resolve(JSON.parse(oReq.responseText))
81+
})
82+
signal?.addEventListener('abort', () => {
83+
oReq.abort()
84+
reject()
85+
})
86+
oReq.open('GET', '/todos')
87+
oReq.send()
88+
})
89+
})
90+
```
91+
92+
## Manual Cancellation
93+
94+
You might want to cancel a query manually. For example, if the request takes a long time to finish, you can allow the user to click a cancel button to stop the request. To do this, you just need to call `queryClient.cancelQueries(key)`. If `promise.cancel` is available or you have consumed the `singal` passed to the query function then React Query will cancel the request.
95+
96+
```js
97+
const [queryKey] = useState('todos')
98+
99+
const query = useQuery(queryKey, await ({ signal }) => {
100+
const resp = fetch('/todos', { signal })
101+
return resp.json()
102+
})
103+
104+
const queryClient = useQueryClient()
105+
106+
return (
107+
<button onClick={(e) => {
108+
e.preventDefault()
109+
queryClient.cancelQueries(queryKey)
110+
}}>Cancel</button>
111+
)
112+
```
113+
114+
## Old `cancel` function
115+
116+
Don't worry! The previous cancellation functionality will continue to work. But we do recommend that you move away from [the withdrawn cancelable promise proposal](https://github.com/tc39/proposal-cancelable-promises) to the [new `AbortSignal` interface](#_top) which has been [stardardized](https://dom.spec.whatwg.org/#interface-abortcontroller) as a general purpose construct for aborting ongoing activities in [most browsers](https://caniuse.com/abortcontroller) and in [Node](https://nodejs.org/api/globals.html#globals_class_abortsignal). The old cancel function might be removed in a future major version.
117+
118+
To integrate with this feature, attach a `cancel` function to the promise returned by your query that implements your request cancellation. When a query becomes out-of-date or inactive, this `promise.cancel` function will be called (if available).
119+
120+
## Using `axios` with `cancel` function
39121
40122
```js
123+
import axios from 'axios'
124+
41125
const query = useQuery('todos', () => {
42-
// Create a new AbortController instance for this request
43-
const controller = new AbortController()
44-
// Get the abortController's signal
45-
const signal = controller.signal
126+
// Create a new CancelToken source for this request
127+
const CancelToken = axios.CancelToken
128+
const source = CancelToken.source()
46129

47-
const promise = fetch('/todos', {
48-
method: 'get',
49-
// Pass the signal to your request
50-
signal,
130+
const promise = axios.get('/todos', {
131+
// Pass the source token to your request
132+
cancelToken: source.token,
51133
})
52134

53135
// Cancel the request if React Query calls the `promise.cancel` method
54-
promise.cancel = () => controller.abort()
136+
promise.cancel = () => {
137+
source.cancel('Query was cancelled by React Query')
138+
}
55139

56140
return promise
57141
})
58142
```
59143
60-
## Manual Cancellation
61-
62-
You might want to cancel a query manually. For example, if the request takes a long time to finish, you can allow the user to click a cancel button to stop the request. To do this, you just need to call `queryClient.cancelQueries(key)`. If `promise.cancel` is available, React Query will cancel the request.
144+
## Using `fetch` with `cancel` function
63145
64146
```js
65-
const [queryKey] = useState('todos')
66-
67-
const query = useQuery(queryKey, () => {
147+
const query = useQuery('todos', () => {
148+
// Create a new AbortController instance for this request
68149
const controller = new AbortController()
150+
// Get the abortController's signal
69151
const signal = controller.signal
70152

71153
const promise = fetch('/todos', {
72154
method: 'get',
155+
// Pass the signal to your request
73156
signal,
74157
})
75158

@@ -78,13 +161,4 @@ const query = useQuery(queryKey, () => {
78161

79162
return promise
80163
})
81-
82-
const queryClient = useQueryClient();
83-
84-
return (
85-
<button onClick={(e) => {
86-
e.preventDefault();
87-
queryClient.cancelQueries(queryKey);
88-
}}>Cancel</button>
89-
)
90164
```

src/core/infiniteQueryBehavior.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
QueryOptions,
77
RefetchQueryFilters,
88
} from './types'
9+
import { getAbortController } from './utils'
910

1011
export function infiniteQueryBehavior<
1112
TQueryFnData,
@@ -23,6 +24,8 @@ export function infiniteQueryBehavior<
2324
const isFetchingPreviousPage = fetchMore?.direction === 'backward'
2425
const oldPages = context.state.data?.pages || []
2526
const oldPageParams = context.state.data?.pageParams || []
27+
const abortController = getAbortController()
28+
const abortSignal = abortController?.signal
2629
let newPageParams = oldPageParams
2730
let cancelled = false
2831

@@ -59,6 +62,7 @@ export function infiniteQueryBehavior<
5962

6063
const queryFnContext: QueryFunctionContext = {
6164
queryKey: context.queryKey,
65+
signal: abortSignal,
6266
pageParam: param,
6367
meta: context.meta,
6468
}
@@ -148,6 +152,7 @@ export function infiniteQueryBehavior<
148152

149153
finalPromiseAsAny.cancel = () => {
150154
cancelled = true
155+
abortController?.abort()
151156
if (isCancelable(promise)) {
152157
promise.cancel()
153158
}

src/core/query.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
getAbortController,
23
Updater,
34
functionalUpdate,
45
isValidTimeout,
@@ -161,8 +162,10 @@ export class Query<
161162
private retryer?: Retryer<TData, TError>
162163
private observers: QueryObserver<any, any, any, any, any>[]
163164
private defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
165+
private abortSignalConsumed: boolean
164166

165167
constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
168+
this.abortSignalConsumed = false
166169
this.defaultOptions = config.defaultOptions
167170
this.setOptions(config.options)
168171
this.observers = []
@@ -331,7 +334,7 @@ export class Query<
331334
// If the transport layer does not support cancellation
332335
// we'll let the query continue so the result can be cached
333336
if (this.retryer) {
334-
if (this.retryer.isTransportCancelable) {
337+
if (this.retryer.isTransportCancelable || this.abortSignalConsumed) {
335338
this.retryer.cancel({ revert: true })
336339
} else {
337340
this.retryer.cancelRetry()
@@ -388,6 +391,7 @@ export class Query<
388391
}
389392

390393
const queryKey = ensureQueryKeyArray(this.queryKey)
394+
const abortController = getAbortController()
391395

392396
// Create query function context
393397
const queryFnContext: QueryFunctionContext<TQueryKey> = {
@@ -396,11 +400,25 @@ export class Query<
396400
meta: this.meta,
397401
}
398402

403+
Object.defineProperty(queryFnContext, 'signal', {
404+
enumerable: true,
405+
get: () => {
406+
if (abortController) {
407+
this.abortSignalConsumed = true
408+
return abortController.signal
409+
}
410+
return undefined
411+
},
412+
})
413+
399414
// Create fetch function
400-
const fetchFn = () =>
401-
this.options.queryFn
402-
? this.options.queryFn(queryFnContext)
403-
: Promise.reject('Missing queryFn')
415+
const fetchFn = () => {
416+
if (!this.options.queryFn) {
417+
return Promise.reject('Missing queryFn')
418+
}
419+
this.abortSignalConsumed = false
420+
return this.options.queryFn(queryFnContext)
421+
}
404422

405423
// Trigger behavior hook
406424
const context: FetchContext<TQueryFnData, TError, TData, TQueryKey> = {
@@ -430,6 +448,7 @@ export class Query<
430448
// Try to fetch the data
431449
this.retryer = new Retryer({
432450
fn: context.fetchFn as () => TData,
451+
abort: abortController?.abort?.bind(abortController),
433452
onSuccess: data => {
434453
this.setData(data as TData)
435454

src/core/retryer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CancelOptions } from './types'
77

88
interface RetryerConfig<TData = unknown, TError = unknown> {
99
fn: () => TData | Promise<TData>
10+
abort?: () => void
1011
onError?: (error: TError) => void
1112
onSuccess?: (data: TData) => void
1213
onFail?: (failureCount: number, error: TError) => void
@@ -67,13 +68,16 @@ export class Retryer<TData = unknown, TError = unknown> {
6768
isTransportCancelable: boolean
6869
promise: Promise<TData>
6970

71+
private abort?: () => void
72+
7073
constructor(config: RetryerConfig<TData, TError>) {
7174
let cancelRetry = false
7275
let cancelFn: ((options?: CancelOptions) => void) | undefined
7376
let continueFn: ((value?: unknown) => void) | undefined
7477
let promiseResolve: (data: TData) => void
7578
let promiseReject: (error: TError) => void
7679

80+
this.abort = config.abort
7781
this.cancel = cancelOptions => cancelFn?.(cancelOptions)
7882
this.cancelRetry = () => {
7983
cancelRetry = true
@@ -139,6 +143,8 @@ export class Retryer<TData = unknown, TError = unknown> {
139143
if (!this.isResolved) {
140144
reject(new CancelledError(cancelOptions))
141145

146+
this.abort?.()
147+
142148
// Cancel transport if supported
143149
if (isCancelable(promiseOrValue)) {
144150
try {

0 commit comments

Comments
 (0)