Skip to content
142 changes: 108 additions & 34 deletions docs/src/pages/guides/query-cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,55 @@ id: query-cancellation
title: Query Cancellation
---

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

- For most applications, ignoring out-of-date queries is sufficient.
- Cancellation APIs may not be available for every query function.
- If cancellation APIs are available, they typically vary in implementation between utilities/libraries (eg. Fetch vs Axios vs XMLHttpRequest).
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.

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):
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).

## Using `fetch`

```js
const query = useQuery('todos', async ({ signal }) => {
const todosResponse = await fetch('/todos', {
// Pass the signal to one fetch
signal,
})
const todos = await todosResponse.json()

const todoDetails = todos.map(async ({ details } => {
const response = await fetch(details, {
// Or pass it to several
signal,
})
return response.json()
})

return Promise.all(todoDetails)
})
```

## Using `axios`

### Using `axios` [v0.22.0+](https://github.com/axios/axios/releases/tag/v0.22.0)

```js
import axios from 'axios'

const query = useQuery('todos', () => {
const query = useQuery('todos', ({ signal }) =>
axios.get('/todos', {
// Pass the signal to `axios`
signal,
})
)
```

### Using an `axios` version less than v0.22.0

```js
import axios from 'axios'

const query = useQuery('todos', ({ signal }) => {
// Create a new CancelToken source for this request
const CancelToken = axios.CancelToken
const source = CancelToken.source()
Expand All @@ -26,50 +61,98 @@ const query = useQuery('todos', () => {
cancelToken: source.token,
})

// Cancel the request if React Query calls the `promise.cancel` method
promise.cancel = () => {
// Cancel the request if React Query signals to abort
signal?.addEventListener('abort', () => {
source.cancel('Query was cancelled by React Query')
}
})

return promise
})
```

## Using `fetch`
## Using `XMLHttpRequest`

```js
const query = useQuery('todos', ({ signal }) => {
return new Promise((resolve, reject) => {
var oReq = new XMLHttpRequest()
oReq.addEventListener('load', () => {
resolve(JSON.parse(oReq.responseText))
})
signal?.addEventListener('abort', () => {
oReq.abort()
reject()
})
oReq.open('GET', '/todos')
oReq.send()
})
})
```

## Manual Cancellation

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.

```js
const [queryKey] = useState('todos')

const query = useQuery(queryKey, await ({ signal }) => {
const resp = fetch('/todos', { signal })
return resp.json()
})

const queryClient = useQueryClient()

return (
<button onClick={(e) => {
e.preventDefault()
queryClient.cancelQueries(queryKey)
}}>Cancel</button>
)
```

## Old `cancel` function

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.

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).

## Using `axios` with `cancel` function

```js
import axios from 'axios'

const query = useQuery('todos', () => {
// Create a new AbortController instance for this request
const controller = new AbortController()
// Get the abortController's signal
const signal = controller.signal
// Create a new CancelToken source for this request
const CancelToken = axios.CancelToken
const source = CancelToken.source()

const promise = fetch('/todos', {
method: 'get',
// Pass the signal to your request
signal,
const promise = axios.get('/todos', {
// Pass the source token to your request
cancelToken: source.token,
})

// Cancel the request if React Query calls the `promise.cancel` method
promise.cancel = () => controller.abort()
promise.cancel = () => {
source.cancel('Query was cancelled by React Query')
}

return promise
})
```

## Manual Cancellation

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.
## Using `fetch` with `cancel` function

```js
const [queryKey] = useState('todos')

const query = useQuery(queryKey, () => {
const query = useQuery('todos', () => {
// Create a new AbortController instance for this request
const controller = new AbortController()
// Get the abortController's signal
const signal = controller.signal

const promise = fetch('/todos', {
method: 'get',
// Pass the signal to your request
signal,
})

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

return promise
})

const queryClient = useQueryClient();

return (
<button onClick={(e) => {
e.preventDefault();
queryClient.cancelQueries(queryKey);
}}>Cancel</button>
)
```
5 changes: 5 additions & 0 deletions src/core/infiniteQueryBehavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
QueryOptions,
RefetchQueryFilters,
} from './types'
import { getAbortController } from './utils'

export function infiniteQueryBehavior<
TQueryFnData,
Expand All @@ -23,6 +24,8 @@ export function infiniteQueryBehavior<
const isFetchingPreviousPage = fetchMore?.direction === 'backward'
const oldPages = context.state.data?.pages || []
const oldPageParams = context.state.data?.pageParams || []
const abortController = getAbortController()
const abortSignal = abortController?.signal
let newPageParams = oldPageParams
let cancelled = false

Expand Down Expand Up @@ -59,6 +62,7 @@ export function infiniteQueryBehavior<

const queryFnContext: QueryFunctionContext = {
queryKey: context.queryKey,
signal: abortSignal,
pageParam: param,
meta: context.meta,
}
Expand Down Expand Up @@ -148,6 +152,7 @@ export function infiniteQueryBehavior<

finalPromiseAsAny.cancel = () => {
cancelled = true
abortController?.abort()
if (isCancelable(promise)) {
promise.cancel()
}
Expand Down
29 changes: 24 additions & 5 deletions src/core/query.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
getAbortController,
Updater,
functionalUpdate,
isValidTimeout,
Expand Down Expand Up @@ -161,8 +162,10 @@ export class Query<
private retryer?: Retryer<TData, TError>
private observers: QueryObserver<any, any, any, any, any>[]
private defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
private abortSignalConsumed: boolean

constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
this.abortSignalConsumed = false
this.defaultOptions = config.defaultOptions
this.setOptions(config.options)
this.observers = []
Expand Down Expand Up @@ -331,7 +334,7 @@ export class Query<
// If the transport layer does not support cancellation
// we'll let the query continue so the result can be cached
if (this.retryer) {
if (this.retryer.isTransportCancelable) {
if (this.retryer.isTransportCancelable || this.abortSignalConsumed) {
this.retryer.cancel({ revert: true })
} else {
this.retryer.cancelRetry()
Expand Down Expand Up @@ -388,6 +391,7 @@ export class Query<
}

const queryKey = ensureQueryKeyArray(this.queryKey)
const abortController = getAbortController()

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

Object.defineProperty(queryFnContext, 'signal', {
enumerable: true,
get: () => {
if (abortController) {
this.abortSignalConsumed = true
Comment thread
jcready marked this conversation as resolved.
return abortController.signal
}
return undefined
},
})

// Create fetch function
const fetchFn = () =>
this.options.queryFn
? this.options.queryFn(queryFnContext)
: Promise.reject('Missing queryFn')
const fetchFn = () => {
if (!this.options.queryFn) {
return Promise.reject('Missing queryFn')
}
this.abortSignalConsumed = false
return this.options.queryFn(queryFnContext)
}

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

Expand Down
6 changes: 6 additions & 0 deletions src/core/retryer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CancelOptions } from './types'

interface RetryerConfig<TData = unknown, TError = unknown> {
fn: () => TData | Promise<TData>
abort?: () => void
onError?: (error: TError) => void
onSuccess?: (data: TData) => void
onFail?: (failureCount: number, error: TError) => void
Expand Down Expand Up @@ -67,13 +68,16 @@ export class Retryer<TData = unknown, TError = unknown> {
isTransportCancelable: boolean
promise: Promise<TData>

private abort?: () => void

constructor(config: RetryerConfig<TData, TError>) {
let cancelRetry = false
let cancelFn: ((options?: CancelOptions) => void) | undefined
let continueFn: ((value?: unknown) => void) | undefined
let promiseResolve: (data: TData) => void
let promiseReject: (error: TError) => void

this.abort = config.abort
this.cancel = cancelOptions => cancelFn?.(cancelOptions)
this.cancelRetry = () => {
cancelRetry = true
Expand Down Expand Up @@ -139,6 +143,8 @@ export class Retryer<TData = unknown, TError = unknown> {
if (!this.isResolved) {
reject(new CancelledError(cancelOptions))

this.abort?.()

// Cancel transport if supported
if (isCancelable(promiseOrValue)) {
try {
Expand Down
Loading