# Parallel queries

> Three independent queries in one screen, fetched at the same time, and the client's count of everything that is fetching.

Three posts, each with a query of its own, all started when the screen
opens, so the three requests run at the same time and the screen waits one
latency, not three. A status line shows `client.isFetching()`, the number of
queries fetching anywhere in the cache. When the number of queries is fixed,
parallel is simply what you get by writing them side by side: a dashboard
with a device's status, its alerts and its energy readings, or a checkout
screen reading the cart, the addresses and the payment methods. When the
number changes at runtime, see [Query collections](https://dualmeta-gmbh.github.io/query_kit/docs/examples/query-collections.md).

Live demo: [Parallel queries](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/parallel-queries), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/parallel_queries)). Several queries in one widget, and the global fetching count.

## What to try

- Watch the screen open: all three cards show a *loading* pill at once, the
  status line reads `fetching=3`, and all three titles arrive together.
- Press *Refetch all*. The titles stay on screen with a *refreshing* pill
  each, `fetching=3` drops back to `fetching=0`, and every strip counts
  `fetches=2`.
- Press *Refetch post 2*: only `post-2` fetches, and the count reads
  `fetching=1`.
- Turn on *Slow post 3*, then press *Refetch all*. Posts 1 and 2 settle while
  post 3 keeps fetching for two more seconds with `fetching=1`. Turning the
  switch on does not refetch by itself: it changes the query function, not
  the key.

## The code

Each post has a `QueryController` of its own, created in `initState` and
disposed in `dispose`. Each card listens to its own controller, and a
`Listenable.merge` over the three lets the toolbar react to any of them.

[`examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart`, lines 65–83](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart#L65-L83):

```dart
late final ShowcaseApi _api;
late final QueryController<Post, Post> _post1;
late final QueryController<Post, Post> _post2;
late final QueryController<Post, Post> _post3;
late final Listenable _all;
bool _slowPost3 = false;

@override
void initState() {
  super.initState();
  // Neither lookup subscribes: the api and the client are fixed for the
  // life of the app, and a subscribing lookup is not allowed here anyway.
  _api = context.getInheritedWidgetOfExactType<ShowcaseScope>()!.api;
  final client = QueryClientProvider.read(context);
  _post1 = QueryController.create(client, postQuery(_api, 1));
  _post2 = QueryController.create(client, postQuery(_api, 2));
  _post3 = QueryController.create(client, postQuery(_api, 3));
  _all = Listenable.merge(<Listenable>[_post1, _post2, _post3]);
}
```

*Refetch all* is one `refetch()` per controller. *Slow post 3* swaps the
options on the existing controller with `setOptions`: same key, another query
function, so the entry and its data are kept and nothing refetches until the
next fetch is asked for.

[`examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart`, lines 96–100](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart#L96-L100):

```dart
void _refetchAll() {
  for (final controller in _controllers) {
    controller.refetch().ignore();
  }
}
```

[`examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart`, lines 102–109](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart#L102-L109):

```dart
void _setSlowPost3(bool slow) {
  setState(() => _slowPost3 = slow);
  // Same key, another query function: the observer keeps its entry and
  // does not refetch by itself. The delay applies from the next fetch on.
  _post3.setOptions(
    postQuery(_api, 3, delay: slow ? _slowDelay : null),
  );
}
```

Each card reads its controller's `value` in a `ListenableBuilder` and picks
its pill from `isLoading` (a first fetch) or `isRefetching` (a fetch over data
already there).

[`examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart`, lines 193–233](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart#L193-L233):

```dart
Widget build(BuildContext context) => ListenableBuilder(
      listenable: controller,
      builder: (context, _) {
        final post = controller.value;
        return SectionCard(
          title: 'Post #$index',
          trailing: switch (post) {
            QueryResult(isLoading: true) => const Pill('loading'),
            QueryResult(isRefetching: true) => const Pill('refreshing'),
            _ => const SizedBox.shrink(),
          },
          child: switch (post) {
            QueryPending() => const Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: <Widget>[
                  SkeletonBox(height: 20),
                  SizedBox(height: 4),
                  SkeletonBox(height: 20, width: 120),
                ],
              ),
            QueryError(:final error, staleData: null) =>
              Notice('$error', error: true),
            QuerySuccess(:final data) ||
            QueryError(staleData: final data!) =>
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  if (post case QueryError(:final error)) ...<Widget>[
                    Notice('Refetch failed: $error', error: true),
                    const SizedBox(height: 8),
                  ],
                  Text(
                    data.title,
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                ],
              ),
          },
        );
      },
    );
```

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

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

```dart
/// Parallel queries: three independent queries in one widget, and the global
/// fetching count. Port-specific — the upstream docs page
/// `guides/parallel-queries.md` says a fixed number of queries needs nothing
/// more than writing them side by side, which is exactly this screen. When the
/// number is *not* fixed, `query-collections` is the screen: it uses
/// `QueriesBuilder` over a list that changes at runtime.
///
/// Each post has a `QueryController` of its own, created in `initState`,
/// disposed in `dispose`, and read through a `ListenableBuilder`; the toolbar
/// reads all three at once through `Listenable.merge`. The status line is
/// `client.isFetching()` — how many queries are fetching right now, across
/// the whole cache — rebuilt on every cache event.
///
/// Proofs (widget tests in `test/features/parallel_queries_test.dart`,
/// end-to-end in `e2e/tests/parallel_queries.spec.ts`): opening the screen
/// starts all three requests at once (`fetching=3`, every strip fetching
/// before any answer) and they all settle; `Refetch all` bumps every strip's
/// `fetches`; `Refetch post 2` bumps only `post-2`; with post 3 held back the
/// other two settle while it still fetches (`fetching=1`); leaving the screen
/// releases every observer.
library;

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

import '../../shared/api.dart';
import '../../shared/cache_listener.dart';
import '../../shared/cache_stats.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 parallelQueriesFeature = Feature(
  id: 'parallel-queries',
  title: 'Parallel queries',
  summary: 'Several queries in one widget, and the global fetching count.',
);

/// One post's query. [delay] is the backend's per-request knob, so one of the
/// three can be made to finish visibly later than the others.
QueryObserverOptions<Post> postQuery(
  ShowcaseApi api,
  int id, {
  Duration? delay,
}) =>
    QueryObserverOptions<Post>(
      queryKey: ShowcaseKeys.post(id),
      queryFn: (context) => api.post(id, signal: context.signal, delay: delay),
    );

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

  @override
  State<ParallelQueriesScreen> createState() => _ParallelQueriesScreenState();
}

class _ParallelQueriesScreenState extends State<ParallelQueriesScreen> {
  static const Duration _slowDelay = Duration(seconds: 2);

  late final ShowcaseApi _api;
  late final QueryController<Post, Post> _post1;
  late final QueryController<Post, Post> _post2;
  late final QueryController<Post, Post> _post3;
  late final Listenable _all;
  bool _slowPost3 = false;

  @override
  void initState() {
    super.initState();
    // Neither lookup subscribes: the api and the client are fixed for the
    // life of the app, and a subscribing lookup is not allowed here anyway.
    _api = context.getInheritedWidgetOfExactType<ShowcaseScope>()!.api;
    final client = QueryClientProvider.read(context);
    _post1 = QueryController.create(client, postQuery(_api, 1));
    _post2 = QueryController.create(client, postQuery(_api, 2));
    _post3 = QueryController.create(client, postQuery(_api, 3));
    _all = Listenable.merge(<Listenable>[_post1, _post2, _post3]);
  }

  @override
  void dispose() {
    _post1.dispose();
    _post2.dispose();
    _post3.dispose();
    super.dispose();
  }

  List<QueryController<Post, Post>> get _controllers =>
      <QueryController<Post, Post>>[_post1, _post2, _post3];

  void _refetchAll() {
    for (final controller in _controllers) {
      controller.refetch().ignore();
    }
  }

  void _setSlowPost3(bool slow) {
    setState(() => _slowPost3 = slow);
    // Same key, another query function: the observer keeps its entry and
    // does not refetch by itself. The delay applies from the next fetch on.
    _post3.setOptions(
      postQuery(_api, 3, delay: slow ? _slowDelay : null),
    );
  }

  @override
  Widget build(BuildContext context) => FeatureScaffold(
        feature: parallelQueriesFeature,
        children: <Widget>[
          // Explicit child nodes: a list row folds every plain text inside
          // it into one label, and `fetching=<n>` is read as an exact text.
          SemanticsGroup(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: ListenableBuilder(
                listenable: _all,
                builder: (context, _) {
                  final anyFetching =
                      _controllers.any((c) => c.value.isFetching);
                  return Wrap(
                    spacing: 12,
                    runSpacing: 8,
                    crossAxisAlignment: WrapCrossAlignment.center,
                    children: <Widget>[
                      _FetchingCount(stats: ShowcaseScope.of(context).stats),
                      Tooltip(
                        message: 'Refetch all',
                        child: FilledButton.tonalIcon(
                          onPressed: anyFetching ? null : _refetchAll,
                          icon: const Icon(Icons.refresh),
                          label: const Text('Refetch all'),
                        ),
                      ),
                      Tooltip(
                        message: 'Refetch post 2',
                        child: OutlinedButton(
                          onPressed: _post2.value.isFetching
                              ? null
                              : () => _post2.refetch().ignore(),
                          child: const Text('Refetch post 2'),
                        ),
                      ),
                    ],
                  );
                },
              ),
            ),
          ),
          SwitchListTile(
            dense: true,
            title: const Text('Slow post 3'),
            value: _slowPost3,
            onChanged: _setSlowPost3,
          ),
          // Side by side where there is room: the point is to watch three
          // requests run at the same time, and a browser-driven test can
          // only read what is in view.
          LayoutBuilder(
            builder: (context, constraints) {
              final cards = <Widget>[
                for (final (index, controller) in _controllers.indexed)
                  _PostCard(index: index + 1, controller: controller),
              ];
              return constraints.maxWidth >= 560
                  ? Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[
                        for (final card in cards) Expanded(child: card),
                      ],
                    )
                  : Column(children: cards);
            },
          ),
          for (final id in <int>[1, 2, 3])
            QueryDebugStrip(queryKey: ShowcaseKeys.post(id), label: 'post-$id'),
        ],
      );
}

/// One post, read from its controller.
class _PostCard extends StatelessWidget {
  const _PostCard({required this.index, required this.controller});

  final int index;
  final QueryController<Post, Post> controller;

  @override
  Widget build(BuildContext context) => ListenableBuilder(
        listenable: controller,
        builder: (context, _) {
          final post = controller.value;
          return SectionCard(
            title: 'Post #$index',
            trailing: switch (post) {
              QueryResult(isLoading: true) => const Pill('loading'),
              QueryResult(isRefetching: true) => const Pill('refreshing'),
              _ => const SizedBox.shrink(),
            },
            child: switch (post) {
              QueryPending() => const Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: <Widget>[
                    SkeletonBox(height: 20),
                    SizedBox(height: 4),
                    SkeletonBox(height: 20, width: 120),
                  ],
                ),
              QueryError(:final error, staleData: null) =>
                Notice('$error', error: true),
              QuerySuccess(:final data) ||
              QueryError(staleData: final data!) =>
                Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    if (post case QueryError(:final error)) ...<Widget>[
                      Notice('Refetch failed: $error', error: true),
                      const SizedBox(height: 8),
                    ],
                    Text(
                      data.title,
                      style: Theme.of(context).textTheme.titleMedium,
                    ),
                  ],
                ),
            },
          );
        },
      );
}

/// `fetching=<n>`: how many queries in the whole cache are fetching, read
/// from the client on every cache event.
///
/// Rebuilt the way the debug strip is: a cache event can arrive from a
/// sibling's first build, when a rebuild has to wait for the frame to end.
class _FetchingCount extends StatefulWidget {
  const _FetchingCount({required this.stats});

  final CacheStats stats;

  @override
  State<_FetchingCount> createState() => _FetchingCountState();
}

class _FetchingCountState extends State<_FetchingCount>
    with PhaseSafeRebuild<_FetchingCount> {
  @override
  void initState() {
    super.initState();
    widget.stats.addListener(scheduleRebuild);
  }

  @override
  void didUpdateWidget(_FetchingCount oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.stats != widget.stats) {
      oldWidget.stats.removeListener(scheduleRebuild);
      widget.stats.addListener(scheduleRebuild);
    }
  }

  @override
  void dispose() {
    widget.stats.removeListener(scheduleRebuild);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final fetching = widget.stats.client.isFetching();
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        Text(
          'client.isFetching()',
          style: Theme.of(context).textTheme.labelLarge,
        ),
        const SizedBox(width: 8),
        Text(
          'fetching=$fetching',
          style: const TextStyle(fontFamily: 'monospace'),
        ),
      ],
    );
  }
}
```

</details>

## Related

- Guides: [Parallel queries](https://dualmeta-gmbh.github.io/query_kit/docs/guides/parallel-queries.md), [Background fetching indicators](https://dualmeta-gmbh.github.io/query_kit/docs/guides/background-fetching-indicators.md)
- Tested by `test/features/parallel_queries_test.dart` (widget) and `e2e/tests/parallel_queries.spec.ts` (browser)
- [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/parallel_queries)
