Skip to main content

Default query function

When every request follows one pattern — a REST path built from the key, say — there is no need to write a function per query. Register one for a key prefix, and every query under that prefix without a queryFn of its own uses it:

final client = QueryClient(
defaultOptions: DefaultOptions(
queries: QueryDefaults(
staleTime: const StaleTime.duration(Duration(seconds: 30)),
retry: const RetryPolicy.times(2),
),
),
);

client.setQueryDefaults(
QueryKey(<Object?>['tasks']),
QueryDefaults(queryFn: (context) => api.listTasks()),
);

The function reads the key it is fetching from context.queryKey, so one function can serve a whole family of keys.

The key is the request​

Take it one step further, and let the key be the request: its second part the path, its third the query parameters. One function, registered once when the app starts, serves every GET of the API:

// In main(), once: every key under ['api'] is a GET of the path it names.
// ['api', '/devices', {'room': 'kitchen'}] is GET /devices?room=kitchen.
client.setQueryDefaults(
QueryKey(<Object?>['api']),
QueryDefaults(
queryFn: (context) {
final parts = context.queryKey.parts;
return api.getJson(
parts[1]! as String,
query: parts.length > 2
? parts[2]! as Map<String, Object?>
: const <String, Object?>{},
signal: context.signal,
);
},
),
);

The api client behind it is whatever the app already uses — with dio, a thin wrapper:

class ApiClient {
ApiClient(this._dio);

final Dio _dio;

Future<Object?> getJson(
String path, {
Map<String, Object?> query = const {},
QueryCancelToken? signal,
}) async {
final token = CancelToken();
signal?.onCancel(token.cancel);
final response = await _dio.get<Object?>(
path,
queryParameters: query,
cancelToken: token,
);
return response.data;
}
}

A query is then nothing but its key — and, because the function hands back raw JSON, a select that parses it:

// lib/data/device_queries.dart — no queryFn: the key is the request.
QuerySelectOptions<Object?, List<Device>> apiRoomDevices(String room) =>
QuerySelectOptions(
queryKey: QueryKey(<Object?>[
'api',
'/devices',
<String, Object?>{'room': room},
]),
select: parseDevices,
);

// A top-level function, so a rebuild hands in an equal select and the
// parsed list is kept rather than parsed again.
List<Device> parseDevices(Object? json) => <Device>[
for (final item in json! as List<Object?>)
Device.fromJson(item! as Map<String, Object?>),
];

Because the parameters are part of the key, two rooms are two cache entries, and invalidating ['api', '/devices'] reaches every room's list.

Name the types​

A default query function is registered for many keys, so its type is erased to Object?. The query that uses it states what it expects, and the client checks the function's answer against it: an answer that is not the query's data type fails the fetch with a QueryDataTypeError.

  • When the default returns a typed value — api.listTasks() returns a List<Task> — write the type on the options: QueryObserverOptions<List<Task>>(queryKey: …). Without a type argument and without a queryFn to infer it from, the query's type is dynamic; see type safety in Dart.
  • When the default returns raw JSON, as above, the cached type is Object? and the parsing is a select: QuerySelectOptions<Object?, List<Device>>. The cache holds the JSON, every reader gets the typed list, and a top-level select function parses once per fetch rather than once per build.

Mutations too​

setMutationDefaults is the same for mutations: a function registered for a mutation key prefix, used by every mutation under it without a function of its own.

client.setMutationDefaults(
QueryKey(<Object?>['api', 'add-device']),
MutationDefaults(
mutationFn: (body) => api.postJson('/devices', body),
),
);
MutationOptions<Object?, Map<String, Object?>, void> addDeviceByKey() =>
MutationOptions.simple(
mutationKey: QueryKey(<Object?>['api', 'add-device']));

The mutation's function is erased the same way, and its answer is checked against the mutation's data type — Object? here. A default mutation function has no context form, and mutation defaults carry no callbacks: onSuccess and the rest stay on the mutation's own options, or go on the mutation cache.

Two levels of defaults​

  • QueryClient(defaultOptions: DefaultOptions(queries: …, mutations: …)) applies to every query or mutation of the client.
  • client.setQueryDefaults(key, QueryDefaults(...)) applies to every query whose key starts with key, and sits above the client-wide default.

An option set on the query itself wins over both. When several registered prefixes match one key — ['api'] and ['api', '/devices'] — their defaults merge in the order they were registered, the later one winning field by field. Registering the same key again replaces its defaults. setMutationDefaults is the matching call for mutations.

The default-query-function screen registers such a function for its ['api', …] keys and has no queryFn anywhere. Its queries each cost one request; press Fetch a missing post and the key's path answers with the backend's 404, shown as the query's error; Create a todo runs a mutation with only a key, which posts once.

Live demoDefault query functionA query function derived from the key, set once as a default.~3 MB, runs in your browser; no server involved.
In React Query

TanStack Query's example sets queryFn in the client's defaultOptions; the same works here, and setQueryDefaults narrows it to a key prefix. The type check has no counterpart there: a TypeScript default function's answer is trusted as whatever the query declared.