# Task manager

> One small, whole app on query_kit — a shared list and detail, optimistic writes with rollback, and a poll that stops once the server confirms.

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 demo: [Task manager](https://dualmeta-gmbh.github.io/query_kit/demo/task_manager/), the whole example app, running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/task_manager/lib)).

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 this | What happens |
|---|---|
| Open a task | The detail renders at once: the row and the detail read the same cache entry, already filled by the list. |
| Rename it, then go straight back | The row already shows the new name; the write is followed by one request for that task and no list refetch. |
| Rename a task to `fail` | The backend refuses. The optimistic name reverts and an error shows. |
| Toggle the reminder | The 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 box | One 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 task | The 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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L25-L31):

```dart
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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L39-L63):

```dart
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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L65-L80):

```dart
/// 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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L83-L126):

```dart
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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L133-L162):

```dart
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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L166-L199):

```dart
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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L205-L217):

```dart
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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart#L222-L259):

```dart
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),
      ),
    );
```

<details>
<summary>The whole policy file</summary>

[`examples/task_manager/lib/src/queries.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/queries.dart):

```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),
      ),
    );
```

</details>

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

| Style | Where | Why there |
|---|---|---|
| `QueryController` | the overview's list | the toolbar and the body are siblings that both need the list |
| `context.query` | each task row | rows read different keys; only the row whose task changed rebuilds |
| `QueryMixin` | the detail screen | already stateful for the rename field; the query and both mutations sit at the top of `build` |
| `QuerySelectBuilder` | the header badge | a 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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/screens/overview.dart#L54-L70):

```dart
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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/screens/overview.dart#L272-L276):

```dart
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](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/task_manager/lib/src/widgets/header.dart#L54-L74):

```dart
    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](https://dualmeta-gmbh.github.io/query_kit/docs/guides/testing.md).

## Related

- Guides: [Optimistic updates](https://dualmeta-gmbh.github.io/query_kit/docs/guides/optimistic-updates.md),
  [Polling](https://dualmeta-gmbh.github.io/query_kit/docs/guides/polling.md),
  [Initial query data](https://dualmeta-gmbh.github.io/query_kit/docs/guides/initial-query-data.md),
  [Four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md)
- [The app's README](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/task_manager), with how to run it against the real backend
- [View the app on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/task_manager/lib)
