Query cancellation
The user opens a device's history, which takes three seconds to load over a slow link, and backs out after one. The request is still running. Its answer will be cached, which is fine — but if the transport could stop it, the phone would save the bandwidth and the gateway the work.
Every query function receives context.signal, a QueryCancelToken. It is
cancelled when the fetch is no longer wanted. What it cannot do by itself is
stop your HTTP request, because Dart has no cancellation primitive that every
HTTP client understands — so the token's onCancel is the bridge you build
once, in the repository.
On QueryCancelToken | |
|---|---|
onCancel(callback) | run callback when the fetch is cancelled — at once, if it already is. Callbacks run synchronously inside the cancel |
isCancelled | whether it has been cancelled; once true, it stays true |
whenCancelled | a future that completes on cancellation, and never otherwise |
throwIfCancelled() | throws a CancelledError if it has been cancelled |
One token is created per fetch and shared by that fetch's retries. A query function never cancels it itself.
With dio
dio has its own CancelToken. Create one per request and cancel it from the
query's token:
// lib/data/device_repository.dart
class DeviceRepository {
DeviceRepository(this._dio);
final Dio _dio;
Future<List<Device>> list({QueryCancelToken? signal}) async {
final cancelToken = CancelToken();
signal?.onCancel(cancelToken.cancel);
final response = await _dio.get<List<Object?>>(
'/devices',
cancelToken: cancelToken,
);
return [
for (final json in response.data!)
Device.fromJson(json! as Map<String, Object?>),
];
}
}
and the query hands the signal over:
QueryObserverOptions(
queryKey: DeviceKeys.list,
queryFn: (context) => deviceRepository.list(signal: context.signal),
// …
)
A cancelled dio request throws a DioException of type cancel. You do not
need to catch it: by then the library has already settled the fetch as
cancelled, and the function's late error is dropped.
With package:http
Since version 1.5, package:http can abort a request through a future — and
whenCancelled is one:
Future<List<Device>> list({QueryCancelToken? signal}) async {
final request = http.AbortableRequest(
'GET',
Uri.parse('$baseUrl/devices'),
abortTrigger: signal?.whenCancelled,
);
final response = await http.Response.fromStream(await _client.send(request));
return decodeDevices(response.body);
}
With an older package:http, or any client that cannot abort, register
nothing: the request runs to completion and its answer is thrown away. That
is also what TanStack Query does with a fetch that ignores its signal.
Work done in steps
A function that does its work in several requests — reading a long log off a device in chunks — checks between steps, so a cancel stops it at the next boundary:
// Reading a long log off a device, chunk by chunk, over a slow link.
Future<List<String>> readDeviceLog(
QueryFunctionContext context,
String deviceId,
) async {
final signal = context.signal;
final lines = <String>[];
for (var chunk = 0; chunk < 20; chunk++) {
signal.throwIfCancelled(); // nobody wants the rest
lines.addAll(
await deviceRepository.readLog(deviceId, chunk, signal: signal),
);
}
return lines;
}
When a fetch is cancelled
The last reader leaves while the fetch runs — the screen was closed, the
search term changed. What happens depends on whether the function read
context.signal:
- It read the signal. Reading it says "I can be stopped". The fetch is
cancelled and the query goes back to the state it held before the fetch
started: a list that had data keeps it; a first load goes back to
pending,idle. - It never read the signal. The request cannot be stopped, so it is left to finish, and its answer is cached for the next reader. Only further retries are called off. (A first load paused while offline is cancelled either way — no request is out.)
client.cancelQueries(filters: …) cancels matching fetches on request —
a Stop button, or the first step of an
optimistic update, so that a refetch in flight
cannot overwrite the optimistic data:
class CancelLogButton extends StatelessWidget {
const CancelLogButton({super.key, required this.deviceId});
final String deviceId;
Widget build(BuildContext context) => TextButton(
onPressed: () => QueryClientProvider.read(context)
.cancelQueries(
filters: QueryFilters(queryKey: deviceLogKey(deviceId)),
)
.ignore(),
child: const Text('Stop reading'),
);
}
A refetch with cancelRefetch: true — the default for a result's
refetch(), invalidateQueries and refetchQueries — cancels the fetch in
flight and starts its own, when the query already has data. A query still
loading its first data joins the running fetch instead.
Search as you type gets cancellation for free when each term is its own key: a new term is a new query, the old one loses its last reader, and a function that read the signal is cancelled.
Try it: the screen below starts its slow fetch as it opens, so wait the
three seconds until Start slow fetch is enabled, then press it and Cancel
within three seconds. cancels= goes up — the token reached the HTTP
client — and fetchStatus= is back to idle. Turn on Ignore the signal
and repeat: the query is cancelled all the same and cancels= stays put,
because nothing told the client to stop; the backend answers in full and the
answer is dropped. Below it, type into Search posts: each new term cancels
the one still in flight.
What cancelQueries does
| Option | Default | Effect |
|---|---|---|
revert | true | each query goes back to the state it held before the fetch, fetchStatus idle |
silent | false | true means "a new fetch is taking over": nothing is recorded as an error |
With the defaults, a reader keeps the data it had. A client.query that was
waiting for the fetch gets that data back, or a CancelledError when there
was none; a refetch() never throws — it completes with the reader's result,
which after the revert is the state from before the fetch. A silently cancelled fetch that nothing
replaces is put back to idle rather than left fetching.
The returned future completes when every matching cancel has settled, and never fails.
A cancelled fetch is never retried. With revert (the default) it leaves no
error on the result; revert: false without silent records the
CancelledError as the query's error.
Disconnecting a device
To stop talking to something — a device the user removed — cancel its queries and remove them, so nothing refetches them:
void disconnect(QueryClient client, QueryKey deviceKey) {
final filters = QueryFilters(queryKey: deviceKey);
client.cancelQueries(filters: filters).ignore();
client.removeQueries(filters: filters);
}
Cancelling a mutation works differently — it fails the run; see cancelling mutations.
context.signal is an AbortSignal there, handed straight to fetch or
axios; here it is a QueryCancelToken, and onCancel or whenCancelled is
the bridge to your HTTP client. Reading the signal marks the fetch as
cancellable in both. A silent cancel with no successor stays fetching
there and goes back to idle here. See
differences from TanStack Query.