# Mutation state

> A badge that counts every running and failed write under one mutation key, read from the mutation cache by a widget that owns none of them.

A "saving…" badge that knows about writes it did not start. The buttons fire
mutations nobody on screen watches; the badge reads the mutation cache
through a `MutationStateController`, which picks the mutations with
`MutationFilters` and turns each into the value the badge needs with
`select`. Two concurrent writes under one key stay two entries, so the badge
can say `2`. Use it wherever the indicator and the writer live apart: a sync
spinner in an app bar while list rows save themselves, an upload counter in a
bottom bar, a "changes pending" hint on a settings screen whose fields each
save on their own.

Live demo: [Mutation state](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/mutation-state), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutation_state)). Every running mutation in the cache, read by a widget that owns none of them.

## What to try

- Press *Add todo* twice in quick succession: the badge reads `saving=2`
  with a spinner, then drops back to `saving=0` as both land, and the list's
  `todos=` count goes up by two.
- Press *Add, failing*: `saving=1` while it runs, then `saving=0` and
  `failed=1`. A failed write is counted as an error, not as pending.
- Watch `tracked`: it counts every run the cache still holds under the key,
  settled ones included, until each is garbage-collected after its `gcTime`.
- Watch `badge-builds`: it goes up only when the selected list of statuses
  changes, not on every cache event.

## The code

Both buttons build the same options, with one `mutationKey` for the badge to
filter on:

[`examples/showcase/lib/features/mutation_state/mutation_state_screen.dart`, lines 70–78](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_state/mutation_state_screen.dart#L70-L78):

```dart
MutationOptions<Todo, String, void> _addOptions({bool fail = false}) =>
    MutationOptions.simple<Todo, String>(
      mutationKey: addTodoKey,
      mutationFn: (String text) =>
          _api.createTodo(text, delay: _slow, fail: fail ? 500 : null),
      onSuccess: (_, __, ___) => _client.invalidateQueries(
        filters: QueryFilters(queryKey: ShowcaseKeys.todos),
      ),
    );
```

Each press runs an owned `MutationController` that nothing reads and that
disposes itself once the run has settled:

[`examples/showcase/lib/features/mutation_state/mutation_state_screen.dart`, lines 80–96](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_state/mutation_state_screen.dart#L80-L96):

```dart
/// Runs a mutation nobody watches: an owned controller that disposes itself
/// when it settles. The badge still sees it, which is the whole point.
void _fireAndForget({bool fail = false}) {
  final controller = MutationController<Todo, String, void>(
    _client,
    _addOptions(fail: fail),
  );
  final text = fail ? 'doomed write' : 'write ${++_added}';
  controller
      .mutateAsync(text)
      .then<void>((_) {}, onError: (Object _) {})
      // Detach only after the badge has seen the settled state; disposing
      // sooner would drop the entry before its last event.
      .whenComplete(() => WidgetsBinding.instance
          .addPostFrameCallback((_) => controller.dispose()));
  setState(() {});
}
```

The badge's controller selects each matching mutation's status. The
selection goes through structural sharing, so an event that leaves the list
equal does not notify, and the badge rebuilds through a `ListenableBuilder`:

[`examples/showcase/lib/features/mutation_state/mutation_state_screen.dart`, lines 176–180](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_state/mutation_state_screen.dart#L176-L180):

```dart
_statuses = MutationStateController<MutationStatus>(
  widget.client,
  filters: MutationFilters(mutationKey: addTodoKey),
  select: (mutation) => mutation.state.status,
);
```

<details>
<summary>The whole screen</summary>

[`examples/showcase/lib/features/mutation_state/mutation_state_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/mutation_state/mutation_state_screen.dart):

```dart
/// Mutation state: what every mutation in the cache is doing, read from a
/// widget that owns none of them. Port-specific — it is
/// `MutationStateController`, the port's `useMutationState`.
///
/// The problem it solves: a mutation is owned by the widget that asks for it,
/// so the widget that wants to show "2 saving…" in an app bar cannot see it.
/// A `MutationStateController` reads the *cache* instead — `MutationFilters`
/// picks the mutations, a `select` turns each one into whatever the badge
/// needs — so the writer and the indicator never have to know each other.
///
/// Two things to notice. Concurrent runs under one key stay separate entries,
/// which is why the badge can say `2` for two saves of the same kind. And the
/// selection goes through structural sharing, so a cache event that leaves the
/// selected list equal does not rebuild the badge at all.
///
/// Proofs (widget tests in `test/features/mutation_state_test.dart`,
/// end-to-end in `e2e/tests/mutation_state.spec.ts`): the badge counts two
/// concurrent adds under one key as two, drops back to zero when they settle,
/// counts a failing mutation as an error rather than as pending, and rebuilds
/// only when the selection actually changed. The todos list under it is the
/// thing being written to, so the invalidation the writers fire has a reader.
library;

import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';

import '../../shared/api.dart';
import '../../shared/chrome.dart';
import '../../shared/debug_strip.dart';
import '../../shared/fact_group.dart';
import '../../shared/feature.dart';
import '../../shared/feature_scaffold.dart';
import '../../shared/models.dart';
import '../../shared/scope.dart';

const Feature mutationStateFeature = Feature(
  id: 'mutation-state',
  title: 'Mutation state',
  summary: 'Every running mutation in the cache, read by a widget that owns '
      'none of them.',
);

/// The key both writers share, so the badge can filter on it and still see
/// two concurrent runs as two entries.
QueryKey get addTodoKey => QueryKey(const <Object?>['todos', 'add']);

class MutationStateScreen extends StatefulWidget {
  const MutationStateScreen({super.key});

  @override
  State<MutationStateScreen> createState() => _MutationStateScreenState();
}

class _MutationStateScreenState extends State<MutationStateScreen> {
  /// Slow enough that two adds are in flight together without a stopwatch:
  /// the test presses twice, then lets the backend answer.
  static const Duration _slow = Duration(milliseconds: 600);

  late final ShowcaseApi _api;
  late final QueryClient _client;
  int _added = 0;

  @override
  void initState() {
    super.initState();
    _api = context.getInheritedWidgetOfExactType<ShowcaseScope>()!.api;
    _client = QueryClientProvider.read(context);
  }

  MutationOptions<Todo, String, void> _addOptions({bool fail = false}) =>
      MutationOptions.simple<Todo, String>(
        mutationKey: addTodoKey,
        mutationFn: (String text) =>
            _api.createTodo(text, delay: _slow, fail: fail ? 500 : null),
        onSuccess: (_, __, ___) => _client.invalidateQueries(
          filters: QueryFilters(queryKey: ShowcaseKeys.todos),
        ),
      );

  /// Runs a mutation nobody watches: an owned controller that disposes itself
  /// when it settles. The badge still sees it, which is the whole point.
  void _fireAndForget({bool fail = false}) {
    final controller = MutationController<Todo, String, void>(
      _client,
      _addOptions(fail: fail),
    );
    final text = fail ? 'doomed write' : 'write ${++_added}';
    controller
        .mutateAsync(text)
        .then<void>((_) {}, onError: (Object _) {})
        // Detach only after the badge has seen the settled state; disposing
        // sooner would drop the entry before its last event.
        .whenComplete(() => WidgetsBinding.instance
            .addPostFrameCallback((_) => controller.dispose()));
    setState(() {});
  }

  @override
  Widget build(BuildContext context) => FeatureScaffold(
        feature: mutationStateFeature,
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Wrap(
              spacing: 8,
              runSpacing: 8,
              children: <Widget>[
                FilledButton.tonal(
                  onPressed: _fireAndForget,
                  child: const Text('Add todo'),
                ),
                OutlinedButton(
                  onPressed: () => _fireAndForget(fail: true),
                  child: const Text('Add, failing'),
                ),
              ],
            ),
          ),
          _SavingBadge(client: _client),
          // The list the writers invalidate. Without a reader on screen the
          // invalidation would be a write into an empty cache, and the strip
          // below would say `status=absent` for good.
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: QueryBuilder<List<Todo>>(
              options: QueryObserverOptions<List<Todo>>(
                queryKey: ShowcaseKeys.todos,
                queryFn: (context) => _api.todos(signal: context.signal),
              ),
              builder: (context, result) => SectionCard(
                title: 'Todos',
                trailing: switch (result) {
                  QueryResult(isLoading: true) => const Pill('loading'),
                  QueryResult(isRefetching: true) => const Pill('refreshing'),
                  _ => const SizedBox.shrink(),
                },
                child: switch (result) {
                  QueryPending() => const SkeletonBox(height: 20),
                  QueryError(:final error, staleData: null) =>
                    Notice('$error', error: true),
                  QuerySuccess(:final data) ||
                  QueryError(staleData: final data!) =>
                    SemanticsGroup(
                      child: Text(
                        'todos=${data.length}',
                        style: const TextStyle(fontFamily: 'monospace'),
                      ),
                    ),
                },
              ),
            ),
          ),
          QueryDebugStrip(queryKey: ShowcaseKeys.todos, label: 'todos'),
        ],
      );
}

/// The indicator. It owns no mutation and is not rebuilt by the writers —
/// only by its own controller, and only when the selection changed.
class _SavingBadge extends StatefulWidget {
  const _SavingBadge({required this.client});

  final QueryClient client;

  @override
  State<_SavingBadge> createState() => _SavingBadgeState();
}

class _SavingBadgeState extends State<_SavingBadge> {
  late final MutationStateController<MutationStatus> _statuses;
  int _builds = 0;

  @override
  void initState() {
    super.initState();
    _statuses = MutationStateController<MutationStatus>(
      widget.client,
      filters: MutationFilters(mutationKey: addTodoKey),
      select: (mutation) => mutation.state.status,
    );
  }

  @override
  void dispose() {
    _statuses.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Padding(
        padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
        child: SemanticsGroup(
          child: ListenableBuilder(
            listenable: _statuses,
            builder: (context, _) {
              _builds++;
              final statuses = _statuses.value;
              final pending = statuses
                  .where((status) => status == MutationStatus.pending)
                  .length;
              final failed = statuses
                  .where((status) => status == MutationStatus.error)
                  .length;
              return Wrap(
                spacing: 12,
                runSpacing: 8,
                crossAxisAlignment: WrapCrossAlignment.center,
                children: <Widget>[
                  if (pending > 0)
                    const SizedBox(
                      height: 16,
                      width: 16,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    ),
                  Text(
                    'saving=$pending',
                    style: const TextStyle(fontFamily: 'monospace'),
                  ),
                  Text(
                    'failed=$failed',
                    style: const TextStyle(fontFamily: 'monospace'),
                  ),
                  Text(
                    'tracked=${statuses.length}',
                    style: const TextStyle(fontFamily: 'monospace'),
                  ),
                  // Proof that an unchanged selection does not rebuild.
                  Text(
                    'badge-builds=$_builds',
                    style: const TextStyle(fontFamily: 'monospace'),
                  ),
                ],
              );
            },
          ),
        ),
      );
}
```

</details>

## Related

- Guides: [Mutation state](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutation-state.md), [Mutations](https://dualmeta-gmbh.github.io/query_kit/docs/guides/mutations.md), [Filters](https://dualmeta-gmbh.github.io/query_kit/docs/guides/filters.md)
- Tested by `test/features/mutation_state_test.dart` (widget) and `e2e/tests/mutation_state.spec.ts` (browser)
- [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/mutation_state)
