Skip to main content

Query functions

The cache does not know about HTTP. It knows one thing about a fetch: whether the Future it was handed completed with a value or with an error. Everything else — retries, QueryError, failureCount, the error callbacks — starts there, and so does the most common bug in a new integration: a server error that never became a Dart error, cached as if it were the data.

A query function is any function that returns a Future of the data. It receives a QueryFunctionContext, and it has one job besides fetching: throw when it fails.

QueryObserverOptions<Device> deviceQuery(String id) => QueryObserverOptions(
queryKey: DeviceKeys.detail(id),
queryFn: (context) => repository.device(id, signal: context.signal),
);
…

Where the function lives​

A queryFn is usually one line that calls a repository — the class in lib/data/ that knows the backend's paths, parses its JSON and turns its refusals into exceptions. The query layer stays free of HTTP, and the repository stays free of caching.

The repository's error type is worth its few lines. Whatever you throw is the error on the result, what a QueryListener shows and what your retry policy is handed, so throw something your UI and your policy can tell apart:

/// What the repository throws when the backend refuses.
class ApiException implements Exception {
const ApiException(this.message, {this.statusCode});

final String message;

/// The HTTP status, or null when no response arrived at all.
final int? statusCode;

/// A 4xx: asking again will not change the answer.
bool get isClientError =>
statusCode != null && statusCode! >= 400 && statusCode! < 500;


String toString() => message;
}

Throwing is how a query fails​

Here the two common HTTP clients differ, and the difference matters:

  • dio throws a DioException for a non-2xx response by default. A function built on it fails correctly as written; the repository's job is to turn the exception into its own type.
  • package:http does not throw on a 4xx or 5xx response: it returns the response. A repository built on it has to check statusCode and throw itself, or a 404 page is cached as a success.

The same repository method on each, with the cache's cancellation handed on to the transport:

// lib/data/device_repository.dart
class DeviceRepository {
DeviceRepository(this._dio);

final Dio _dio;

Future<Device> device(String id, {QueryCancelToken? signal}) async {
final json = await _get('/devices/$id', signal);
return Device.fromJson(json! as Map<String, Object?>);
}

Future<Object?> _get(String path, QueryCancelToken? signal) async {
// The cache's cancellation, handed on to dio.
final cancelToken = CancelToken();
signal?.onCancel(cancelToken.cancel);
try {
final response = await _dio.get<Object?>(path, cancelToken: cancelToken);
return response.data;
} on DioException catch (error) {
// Cancelled because nobody wants the answer: not a failure to report.
if (CancelToken.isCancel(error)) rethrow;
// dio throws for any non-2xx status; make it an error the app knows.
throw ApiException(
error.message ?? 'Request failed',
statusCode: error.response?.statusCode,
);
}
}
}

Both use import 'package:query_kit/query_kit.dart' for QueryCancelToken, so the data layer needs no Flutter. With an error type in hand, a retry policy can stop wasting attempts on answers that will not change:

// A 404 or a 403 will not change on the third try; a timeout might.
retry: RetryPolicy.when(
(failureCount, error, _) =>
failureCount < 3 && !(error is ApiException && error.isClientError),
),

See query retries for the rest of the policy.

The context​

QueryFunctionContext carries:

  • signal — a QueryCancelToken that is cancelled when the fetch is no longer wanted: the query was cancelled, or its last reader left while it ran. Reading signal is what marks the fetch as cancellable, so read it only if you hand it on. onCancel(callback) runs the callback on cancel — at once, if that has already happened — and isCancelled, throwIfCancelled() and whenCancelled cover a function that does its own work in steps. See query cancellation.
  • queryKey — the key being fetched, so one function can serve a whole family of keys.
  • meta — the query's meta option, for information that is not part of the key (a logging tag, a toast policy — see global callbacks).
  • client — the QueryClient running the fetch.

One function for a family of keys reads what it needs back from the key:

// One function for every detail key: the id is read back from the key.
Future<Device> fetchDevice(QueryFunctionContext context) {
final id = context.queryKey.parts[2]! as String;
return repository.device(id, signal: context.signal);
}

The key's parts are Object?, so reading one back is a cast. That is the price of one shared function; a function per query that closes over its id has none. See also default query function.

An infinite query's function is pageFn, handed an InfinitePageContext that also carries the pageParam and the direction; see infinite queries.

The showcase's cancellation screen shows what handing the signal on buys. Press Start slow fetch, then Cancel: the query goes back to where it was and cancels counts one onCancel. Switch Ignore the signal on and do it again — the query is still cancelled, but cancels stays put: the request ran to its end at the backend and only its answer was thrown away:

Live demoCancellationA query cancelled is a request aborted.~3 MB, runs in your browser; no server involved.

Traps​

  • Swallowing the error. A try/catch that logs and returns an empty list turns every failure into a success — no retry, no error state, and an empty list cached as the truth. Catch to translate, then throw.
  • Catching the cancellation. When the cache cancels, the transport throws (dio a cancelled DioException, package:http a RequestAbortedException). Rethrow it or let it pass; the cache has already settled the query's state and drops what the function does next.
  • Reading widget state. The function runs when the cache decides — on mount, on focus, on reconnect, on an invalidation — possibly after the widget that first asked is gone. It reads everything from the key and from stable objects it closes over (a repository), never from a BuildContext.
  • A parse error is a failure too. A TypeError from a missing JSON field fails the query like any other error — and is retried three times by default. Parse inside the repository and throw your own type if the message should reach a user.
In React Query

The same contract as queryFn in TanStack Query: resolve with data or throw. signal is a QueryCancelToken rather than an AbortSignal, because Dart has no cancellation primitive every HTTP client shares; onCancel is the bridge. fetch's "does not reject on 4xx" trap is package:http's here. See differences from TanStack Query.