Skip to main content

Task manager

The showcase is a catalogue: one screen per feature, each on its own. The task manager is the other kind of example, an ordinary small app that needs six of those features at once, so you can see how they compose. It has an overview with search and a sync badge, a detail screen with a rename field and a reminder switch, and a backend that is slow on purpose and fails on cue. Each row of the table below is covered by a widget test.

Live demoTask managerOne small app on query_kit: optimistic writes, rollback, a poll that stops, one cache entry read by two screens.~3 MB, runs in your browser; no server involved.

The demo answers from an in-memory copy of the backend inside the page, as slow as the real one: about 900 ms for the list, 350 ms for one task, 700 ms for a write, and three seconds for the reminder scheduler to confirm. It starts with three tasks.

What to try​

Try thisWhat happens
Open a taskThe detail renders at once: the row and the detail read the same cache entry, already filled by the list.
Rename it, then go straight backThe row already shows the new name; the write is followed by one request for that task and no list refetch.
Rename a task to failThe backend refuses. The optimistic name reverts and an error shows.
Toggle the reminderThe switch flips at once, the screen waits for the scheduler, polls until the confirmation arrives about three seconds later, then stops polling.
Type in the search boxOne request per pause in typing, not one per keystroke.
Add a task (+)Every list is invalidated: which tasks exist is the list's concern.
Delete a taskThe row disappears at once. Deletes alternate: the first is refused and springs back with a notice; the next goes through.
Look at the header badge"x of y synced" is a second shape of the list's own cache entry; it costs no request.

The cache policy, in one file​

Everything the app decides about caching, fetching, seeding, polling and rolling back lives in lib/src/queries.dart. The screens only call the functions it exports. What follows walks through it top to bottom.

Client defaults​

The app turns the eager defaults down: no refetch when the app comes back to the foreground or the network returns, and one retry instead of three. A backend that needs most of a second per list should not be asked again every time the window regains focus. The acceptance tests build their client from the same constant, so they test the policy the app ships.

examples/task_manager/lib/src/queries.dart · lines 25–31
const DefaultOptions appDefaultOptions = DefaultOptions(
queries: QueryDefaults(
refetchOnWindowFocus: RefetchOn.never,
refetchOnReconnect: RefetchOn.never,
retry: RetryPolicy.times(1),
),
);

The list seeds each task​

Two kinds of query split the data. The list query owns which tasks exist; a per-task query owns what each task is. When a list arrives, its query function writes every task into that task's own entry. initialData could not do this: it is read only while an entry is empty, so later list responses would never reach a row that had already been built. Seeding also stamps each entry fresh, so rendering the rows does not start a burst of per-task fetches. The options are one shared function, never re-declared per screen, so every observer of the key runs the same query function.

examples/task_manager/lib/src/queries.dart · lines 39–63
QueryObserverOptions<TaskListResponse> taskListQuery(
QueryClient client,
TaskApi api,
TaskFilters filters,
) =>
QueryObserverOptions<TaskListResponse>(
queryKey: TaskKeys.list(filters),
queryFn: (context) async {
final result = await api.listTasks(filters, signal: context.signal);
// Push the list response into each per-task cache.
//
// `initialData` cannot do this job: it is only consulted when a cache
// entry is empty, so once a row has been built, later list responses
// have no path into that entry and the row renders its own stale copy
// forever.
//
// Seeding also stamps those entries fresh, so building the rows never
// triggers a burst of per-task fetches.
for (final task in result.tasks) {
client.setQueryData<Task>(TaskKeys.detail(task.id), task);
}
return result;
},
staleTime: const StaleTime.duration(Duration(seconds: 45)),
);

Two widgets, two shapes, one request​

The header's sync badge uses the same key, function and stale time as the unfiltered list, and adds a select. It reads the entry the overview already fetched, so it costs nothing.

examples/task_manager/lib/src/queries.dart · lines 65–80
/// Used by the header. Same key and same options as the overview's unfiltered
/// list, so this costs no extra request — `select` derives from the cache entry
/// that is already there. Two widgets, two shapes of the same data, one fetch.
QuerySelectOptions<TaskListResponse, ({int synced, int total})>
syncedTasksQuery(QueryClient client, TaskApi api) {
final list = taskListQuery(client, api, TaskFilters.all);
return QuerySelectOptions<TaskListResponse, ({int synced, int total})>(
queryKey: list.queryKey,
queryFn: list.queryFn,
staleTime: list.staleTime,
select: (data) => (
synced: data.tasks.where((task) => task.synced).length,
total: data.tasks.length,
),
);
}

The per-task query: a fallback seed and a poll that stops​

Both the overview's rows and the detail screen read taskQuery. If a task is opened before any list has seeded it, the query looks for it in whatever lists the cache holds and uses that as initial data, with the list's timestamp, so old data does not pass for freshly fetched and still revalidates. Its refetchInterval is a function of the data: while the task has a reminder write the scheduler has not confirmed, it polls every half second; once the server confirms, the function returns null and polling stops. The poll keeps running in the background, so a confirmation that lands while the user looks away is still picked up.

examples/task_manager/lib/src/queries.dart · lines 83–126
QueryObserverOptions<Task> taskQuery(
QueryClient client,
TaskApi api,
String id,
) {
({QueryKey key, Task match})? findInLists() {
for (final (key, data) in client.getQueriesData<TaskListResponse>(
filters: QueryFilters(queryKey: TaskKeys.lists),
)) {
for (final task in data?.tasks ?? const <Task>[]) {
if (task.id == id) {
return (key: key, match: task);
}
}
}
return null;
}

final seed = findInLists();

return QueryObserverOptions<Task>(
queryKey: TaskKeys.detail(id),
queryFn: (context) => api.getTask(id, signal: context.signal),
staleTime: const StaleTime.duration(Duration(seconds: 45)),
// Fallback seed for the case where a task is opened before any list
// response has seeded it. Inheriting the list's timestamp matters: dated
// data must not look freshly fetched, or it would never revalidate.
initialData:
seed == null ? null : InitialData<Task>.compute(() => seed.match),
initialDataUpdatedAt: seed == null
? null
: client.getQueryState<TaskListResponse>(seed.key)?.dataUpdatedAt,
// While the scheduler has an unconfirmed write outstanding, poll until it
// settles. The poll must survive the app losing focus, otherwise a
// confirmation that lands while the user glances away is never picked up.
refetchInterval: RefetchInterval.dynamic((query) {
final task = query.state.data;
return task is Task && task.reminderPending
? const Duration(milliseconds: 500)
: null;
}),
refetchIntervalInBackground: true,
);
}

Optimistic rename with rollback​

The rename cancels any fetch of the task in flight (so a slow response cannot overwrite the patch), keeps the current value, and patches the entry. If the server refuses, onError writes the kept value back. Either way, one invalidation of one key reconciles both screens, because both read it.

examples/task_manager/lib/src/queries.dart · lines 133–162
MutationOptions<Task, RenameInput, TaskSnapshot> renameTaskMutation(
QueryClient client,
TaskApi api,
) =>
MutationOptions<Task, RenameInput, TaskSnapshot>(
mutationKey: QueryKey(const <Object?>['renameTask']),
mutationFn: (input) => api.renameTask(input.id, input.name),
onMutate: (input) async {
final key = TaskKeys.detail(input.id);
// Stop in-flight fetches so a stale response cannot overwrite the
// patch.
await client.cancelQueries(filters: QueryFilters(queryKey: key));
final previous = client.getQueryData<Task>(key);
client.updateQueryData<Task>(
key,
(old) => old?.copyWith(name: input.name),
);
return previous;
},
onError: (_, __, input, previous) {
if (previous != null) {
client.setQueryData<Task>(TaskKeys.detail(input.id), previous);
}
},
// One invalidation, one key. The detail screen and the overview row both
// read this query, so both reconcile from the single refetch.
onSettled: (_, __, ___, input, ____) => client.invalidateQueries(
filters: QueryFilters(queryKey: TaskKeys.detail(input.id)),
),
);

A reminder that is accepted before it is confirmed​

The reminder write puts the requested value in a separate field, reminderTarget, which the screen shows in preference to the confirmed one. It deliberately does not mark the task as pending: that would start the poll before the write had reached the server, and the first poll would read the old state. The server's answer carries pending: true, onSuccess writes it into the cache, and that is what starts the poll in taskQuery. No invalidation: the poll is the reconciliation.

examples/task_manager/lib/src/queries.dart · lines 166–199
MutationOptions<Task, ReminderInput, TaskSnapshot> setReminderMutation(
QueryClient client,
TaskApi api,
) =>
MutationOptions<Task, ReminderInput, TaskSnapshot>(
mutationKey: QueryKey(const <Object?>['setReminder']),
mutationFn: (input) => api.setReminder(input.id, value: input.value),
onMutate: (input) async {
final key = TaskKeys.detail(input.id);
await client.cancelQueries(filters: QueryFilters(queryKey: key));
final previous = client.getQueryData<Task>(key);
// Write the requested value to `reminderTarget`, not to the
// confirmed field, and deliberately leave `pending` alone: setting it
// here would start the confirmation poll before the write had even
// reached the backend, and the first poll would read pre-write state.
// The UI renders `target ?? reminder`, so the switch still
// flips instantly, and every later response agrees with it.
client.updateQueryData<Task>(
key,
(old) => old?.copyWith(reminderTarget: input.value),
);
return previous;
},
onError: (_, __, input, previous) {
if (previous != null) {
client.setQueryData<Task>(TaskKeys.detail(input.id), previous);
}
},
// The accepted response carries `pending: true`, which starts the poll
// above. No invalidation here — the poll is already the reconciliation
// loop.
onSuccess: (accepted, input, ___) =>
client.setQueryData<Task>(TaskKeys.detail(input.id), accepted),
);

Create and delete touch membership​

Creating a task has no optimistic step, so it uses MutationOptions.simple and invalidates every list. Deleting removes the row from every cached list at once and keeps a snapshot of all of them for the rollback. The task's own entry is removed only once the server agrees, so a refused delete springs back to a row whose detail still renders from cache.

examples/task_manager/lib/src/queries.dart · lines 205–217
MutationOptions<Task, CreateInput, void> createTaskMutation(
QueryClient client,
TaskApi api,
) =>
MutationOptions.simple(
mutationKey: QueryKey(const <Object?>['createTask']),
mutationFn: (input) => api.createTask(
name: input.name, project: input.project, priority: input.priority),
// Membership is a list concern, so this one invalidates every list.
onSuccess: (_, __, ___) => client.invalidateQueries(
filters: QueryFilters(queryKey: TaskKeys.lists),
),
);
examples/task_manager/lib/src/queries.dart · lines 222–259
MutationOptions<String, String, ListSnapshot> deleteTaskMutation(
QueryClient client,
TaskApi api,
) =>
MutationOptions<String, String, ListSnapshot>(
mutationKey: QueryKey(const <Object?>['deleteTask']),
mutationFn: api.deleteTask,
onMutate: (id) async {
final lists = QueryFilters(queryKey: TaskKeys.lists);
await client.cancelQueries(filters: lists);
final snapshot =
client.getQueriesData<TaskListResponse>(filters: lists);
client.updateQueriesData<TaskListResponse>(
(old) => old?.withTasks(
old.tasks.where((task) => task.id != id).toList(),
),
filters: lists,
);
return snapshot;
},
onError: (_, __, ___, snapshot) {
for (final (key, data)
in snapshot ?? const <(QueryKey, TaskListResponse?)>[]) {
if (data != null) {
client.setQueryData<TaskListResponse>(key, data);
}
}
},
// The per-task entry goes only once the server has agreed: a refused
// delete springs the row back, and the detail screen behind it must
// still render from the seeded entry rather than fetch it again.
onSuccess: (_, id, __) => client.removeQueries(
filters: QueryFilters(queryKey: TaskKeys.detail(id)),
),
onSettled: (_, __, ___, ____, _____) => client.invalidateQueries(
filters: QueryFilters(queryKey: TaskKeys.lists),
),
);
The whole policy file
examples/task_manager/lib/src/queries.dart
/// The whole cache policy of this demo lives in this file — every staleness,
/// seeding, polling and rollback rule the app has, in about 230 lines
/// including comments. Note how small it is compared to the manual reload
/// wiring it replaces.
///
/// Shape: the list query owns *which* tasks exist, and a per-task query
/// owns *what each one is*. Both the overview rows and the detail screen read
/// the same per-task query, so invalidating one task key updates both
/// screens — there is no cross-cache patching to keep in sync.
library;

import 'package:query_kit_flutter/query_kit_flutter.dart';

import 'api.dart';
import 'models.dart';

/// The client-wide defaults.
///
/// This app deliberately turns the aggressive ones off: no refetch when it
/// comes back to the foreground or the network returns, and one retry instead
/// of three — a demo backend that answers in ~900 ms should not be asked twice
/// for the same thing every time the window regains focus. Both the app and
/// the acceptance suite build their client from this, so the tests run the
/// policy the app ships with.
const DefaultOptions appDefaultOptions = DefaultOptions(
queries: QueryDefaults(
refetchOnWindowFocus: RefetchOn.never,
refetchOnReconnect: RefetchOn.never,
retry: RetryPolicy.times(1),
),
);

/// Options for a task-list query, shared by every caller.
///
/// They have to be shared rather than re-declared per screen: two observers on
/// one key with two different query functions is a coin flip over which one
/// actually runs, and the seeding below would silently stop happening depending
/// on which widget built first.
QueryObserverOptions<TaskListResponse> taskListQuery(
QueryClient client,
TaskApi api,
TaskFilters filters,
) =>
QueryObserverOptions<TaskListResponse>(
queryKey: TaskKeys.list(filters),
queryFn: (context) async {
final result = await api.listTasks(filters, signal: context.signal);
// Push the list response into each per-task cache.
//
// `initialData` cannot do this job: it is only consulted when a cache
// entry is empty, so once a row has been built, later list responses
// have no path into that entry and the row renders its own stale copy
// forever.
//
// Seeding also stamps those entries fresh, so building the rows never
// triggers a burst of per-task fetches.
for (final task in result.tasks) {
client.setQueryData<Task>(TaskKeys.detail(task.id), task);
}
return result;
},
staleTime: const StaleTime.duration(Duration(seconds: 45)),
);

/// Used by the header. Same key and same options as the overview's unfiltered
/// list, so this costs no extra request — `select` derives from the cache entry
/// that is already there. Two widgets, two shapes of the same data, one fetch.
QuerySelectOptions<TaskListResponse, ({int synced, int total})>
syncedTasksQuery(QueryClient client, TaskApi api) {
final list = taskListQuery(client, api, TaskFilters.all);
return QuerySelectOptions<TaskListResponse, ({int synced, int total})>(
queryKey: list.queryKey,
queryFn: list.queryFn,
staleTime: list.staleTime,
select: (data) => (
synced: data.tasks.where((task) => task.synced).length,
total: data.tasks.length,
),
);
}

/// Used by both the overview rows and the detail screen.
QueryObserverOptions<Task> taskQuery(
QueryClient client,
TaskApi api,
String id,
) {
({QueryKey key, Task match})? findInLists() {
for (final (key, data) in client.getQueriesData<TaskListResponse>(
filters: QueryFilters(queryKey: TaskKeys.lists),
)) {
for (final task in data?.tasks ?? const <Task>[]) {
if (task.id == id) {
return (key: key, match: task);
}
}
}
return null;
}

final seed = findInLists();

return QueryObserverOptions<Task>(
queryKey: TaskKeys.detail(id),
queryFn: (context) => api.getTask(id, signal: context.signal),
staleTime: const StaleTime.duration(Duration(seconds: 45)),
// Fallback seed for the case where a task is opened before any list
// response has seeded it. Inheriting the list's timestamp matters: dated
// data must not look freshly fetched, or it would never revalidate.
initialData:
seed == null ? null : InitialData<Task>.compute(() => seed.match),
initialDataUpdatedAt: seed == null
? null
: client.getQueryState<TaskListResponse>(seed.key)?.dataUpdatedAt,
// While the scheduler has an unconfirmed write outstanding, poll until it
// settles. The poll must survive the app losing focus, otherwise a
// confirmation that lands while the user glances away is never picked up.
refetchInterval: RefetchInterval.dynamic((query) {
final task = query.state.data;
return task is Task && task.reminderPending
? const Duration(milliseconds: 500)
: null;
}),
refetchIntervalInBackground: true,
);
}

/// What an optimistic write stashes so it can be rolled back.
typedef TaskSnapshot = Task?;

typedef RenameInput = ({String id, String name});

MutationOptions<Task, RenameInput, TaskSnapshot> renameTaskMutation(
QueryClient client,
TaskApi api,
) =>
MutationOptions<Task, RenameInput, TaskSnapshot>(
mutationKey: QueryKey(const <Object?>['renameTask']),
mutationFn: (input) => api.renameTask(input.id, input.name),
onMutate: (input) async {
final key = TaskKeys.detail(input.id);
// Stop in-flight fetches so a stale response cannot overwrite the
// patch.
await client.cancelQueries(filters: QueryFilters(queryKey: key));
final previous = client.getQueryData<Task>(key);
client.updateQueryData<Task>(
key,
(old) => old?.copyWith(name: input.name),
);
return previous;
},
onError: (_, __, input, previous) {
if (previous != null) {
client.setQueryData<Task>(TaskKeys.detail(input.id), previous);
}
},
// One invalidation, one key. The detail screen and the overview row both
// read this query, so both reconcile from the single refetch.
onSettled: (_, __, ___, input, ____) => client.invalidateQueries(
filters: QueryFilters(queryKey: TaskKeys.detail(input.id)),
),
);

typedef ReminderInput = ({String id, bool value});

MutationOptions<Task, ReminderInput, TaskSnapshot> setReminderMutation(
QueryClient client,
TaskApi api,
) =>
MutationOptions<Task, ReminderInput, TaskSnapshot>(
mutationKey: QueryKey(const <Object?>['setReminder']),
mutationFn: (input) => api.setReminder(input.id, value: input.value),
onMutate: (input) async {
final key = TaskKeys.detail(input.id);
await client.cancelQueries(filters: QueryFilters(queryKey: key));
final previous = client.getQueryData<Task>(key);
// Write the requested value to `reminderTarget`, not to the
// confirmed field, and deliberately leave `pending` alone: setting it
// here would start the confirmation poll before the write had even
// reached the backend, and the first poll would read pre-write state.
// The UI renders `target ?? reminder`, so the switch still
// flips instantly, and every later response agrees with it.
client.updateQueryData<Task>(
key,
(old) => old?.copyWith(reminderTarget: input.value),
);
return previous;
},
onError: (_, __, input, previous) {
if (previous != null) {
client.setQueryData<Task>(TaskKeys.detail(input.id), previous);
}
},
// The accepted response carries `pending: true`, which starts the poll
// above. No invalidation here — the poll is already the reconciliation
// loop.
onSuccess: (accepted, input, ___) =>
client.setQueryData<Task>(TaskKeys.detail(input.id), accepted),
);

typedef CreateInput = ({String name, String? project, Priority? priority});

/// No optimistic step, so no `onMutate` and nothing to roll back:
/// `MutationOptions.simple` is the form for that.
MutationOptions<Task, CreateInput, void> createTaskMutation(
QueryClient client,
TaskApi api,
) =>
MutationOptions.simple(
mutationKey: QueryKey(const <Object?>['createTask']),
mutationFn: (input) => api.createTask(
name: input.name, project: input.project, priority: input.priority),
// Membership is a list concern, so this one invalidates every list.
onSuccess: (_, __, ___) => client.invalidateQueries(
filters: QueryFilters(queryKey: TaskKeys.lists),
),
);

/// Every list entry as it was before an optimistic delete.
typedef ListSnapshot = List<(QueryKey, TaskListResponse?)>;

MutationOptions<String, String, ListSnapshot> deleteTaskMutation(
QueryClient client,
TaskApi api,
) =>
MutationOptions<String, String, ListSnapshot>(
mutationKey: QueryKey(const <Object?>['deleteTask']),
mutationFn: api.deleteTask,
onMutate: (id) async {
final lists = QueryFilters(queryKey: TaskKeys.lists);
await client.cancelQueries(filters: lists);
final snapshot =
client.getQueriesData<TaskListResponse>(filters: lists);
client.updateQueriesData<TaskListResponse>(
(old) => old?.withTasks(
old.tasks.where((task) => task.id != id).toList(),
),
filters: lists,
);
return snapshot;
},
onError: (_, __, ___, snapshot) {
for (final (key, data)
in snapshot ?? const <(QueryKey, TaskListResponse?)>[]) {
if (data != null) {
client.setQueryData<TaskListResponse>(key, data);
}
}
},
// The per-task entry goes only once the server has agreed: a refused
// delete springs the row back, and the detail screen behind it must
// still render from the seeded entry rather than fetch it again.
onSuccess: (_, id, __) => client.removeQueries(
filters: QueryFilters(queryKey: TaskKeys.detail(id)),
),
onSettled: (_, __, ___, ____, _____) => client.invalidateQueries(
filters: QueryFilters(queryKey: TaskKeys.lists),
),
);

The four call styles, one per screen​

The binding offers four equal ways to read a query. The app uses each where its screen's shape suits it, which also shows that they share one cache without friction.

StyleWhereWhy there
QueryControllerthe overview's listthe toolbar and the body are siblings that both need the list
context.queryeach task rowrows read different keys; only the row whose task changed rebuilds
QueryMixinthe detail screenalready stateful for the rename field; the query and both mutations sit at the top of build
QuerySelectBuilderthe header badgea leaf widget, so the builder stays visible in the tree

The overview creates its controller once and re-keys it in place when the filters change; the old entry stays cached until its garbage-collection time.

examples/task_manager/lib/src/screens/overview.dart · lines 54–70
void didChangeDependencies() {
super.didChangeDependencies();
final filters = AppScope.of(context).filters;
final options = taskListQuery(_client, widget.api, filters);
if (_list == null) {
_list = QueryController<TaskListResponse, TaskListResponse>(
_client,
options,
);
} else if (filters != _filters) {
// A changed filter re-keys the list. The observer switches queries in
// place; the previous entry stays cached and is garbage-collected on its
// own schedule.
_list!.setOptions(options);
}
_filters = filters;
}

A row reads its own task:

examples/task_manager/lib/src/screens/overview.dart · lines 272–276
final client = QueryClientProvider.of(context);
final task = context.query(taskQuery(client, api, id)).dataOrNull;
if (task == null) {
return const SizedBox.shrink();
}

The badge selects from the list's entry:

examples/task_manager/lib/src/widgets/header.dart · lines 54–74
QuerySelectBuilder<TaskListResponse, ({int synced, int total})>(
options: syncedTasksQuery(client, api),
builder: (context, result) => switch (result) {
QuerySuccess(:final data) => StatusPill(
label: '${data.synced} of ${data.total} synced',
color: data.synced == data.total
? AppColors.accent
: AppColors.muted,
background: data.synced == data.total
? AppColors.accentSoft
: AppColors.ground,
dot: true,
),
QueryError() => StatusPill(
label: 'Server offline',
color: AppColors.danger,
background: AppColors.dangerSoft,
),
QueryPending() => const SkeletonBox(width: 128, height: 22),
},
),

How it is tested​

Three layers, all in examples/task_manager/:

  • Widget tests (test/acceptance_test.dart): one test per row of the feature checklist in the app's README, plus regressions. They run the real app, with its own client defaults, against the same in-memory backend the live demo uses, wired in as a dio adapter with its latencies at zero unless a test sets one. Only the transport is replaced; the app's api class, JSON handling, errors and cancellation all run.
  • A contract test (test/backend_contract_test.dart): the same cases against the in-memory backend and, when a server is running, against the real express server, so the stand-in cannot quietly drift from what it stands in for.
  • End-to-end tests (e2e/tests/tasks.spec.ts): the real web build in Chromium against the real server, asserting on what a user sees and on the wire: how many requests a screen costs, that a rename shows before the write returns (the test holds the request at the network layer rather than timing it), that the reminder poll stops, that an unreachable backend is retried exactly once.

The widget-test teardown a QueryClient needs is on Testing.