Skip to main content

Parallel queries

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.

Live demoParallel queriesSeveral queries in one widget, and the global fetching count.~3 MB, runs in your browser; no server involved.

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
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;


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
void _refetchAll() {
for (final controller in _controllers) {
controller.refetch().ignore();
}
}
examples/showcase/lib/features/parallel_queries/parallel_queries_screen.dart · lines 102–109
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
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,
),
],
),
},
);
},
);
The whole screen
examples/showcase/lib/features/parallel_queries/parallel_queries_screen.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});


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;


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]);
}


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


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;


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;


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

class _FetchingCountState extends State<_FetchingCount>
with PhaseSafeRebuild<_FetchingCount> {

void initState() {
super.initState();
widget.stats.addListener(scheduleRebuild);
}


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


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


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'),
),
],
);
}
}