Skip to main content

Background fetching indicators

A screen that already shows data should keep showing it while that data is refreshed. Dropping back to a full-screen spinner every time the app comes to the foreground, or every time a mutation invalidates the list, makes a fast app feel slow. What the screen wants instead is a quiet sign that something is in flight: a thin bar under the app bar, a small spinner in a header.

A result answers two separate questions:

  • status — does the query have data? QueryPending, QuerySuccess or QueryError, the three variants of the sealed result.
  • fetchStatus — is a request running right now? fetching, paused or idle.

A background refresh is the combination the spinner-only approach misses: QuerySuccess and fetching. See queries for the full table.

On the resultTrue when
isFetchingany fetch of this query is running — the first load and every refresh
isLoadingthe first load: pending and fetching
isRefetchinga refresh of data already there: fetching and not pending
isPauseda fetch wants to run but is waiting for the network or for focus

First load or refresh​

The device list of a smart-home app, with its keys and options in one file:

// lib/data/device_queries.dart
abstract final class DeviceKeys {
static final QueryKey all = QueryKey(<Object?>['devices']);
static final QueryKey list = all.append(<Object?>['list']);
static QueryKey byKind(String kind) => all.append(<Object?>['kind', kind]);
static QueryKey page(int page) => all.append(<Object?>['page', page]);
static QueryKey detail(String id) => all.append(<Object?>['detail', id]);
static QueryKey activity(String id) => all.append(<Object?>['activity', id]);
}

abstract final class DeviceQueries {
static QueryObserverOptions<List<Device>> list() => QueryObserverOptions(
queryKey: DeviceKeys.list,
queryFn: (context) => deviceRepository.list(signal: context.signal),
);
}

The body of the list screen switches on the result. The first load gets the whole area; a refresh gets two pixels above data that stays where it is:

Widget deviceListBody(QueryResult<List<Device>> devices) => switch (devices) {
// Nothing to show yet: the whole area is the spinner.
QueryPending() => const Center(child: CircularProgressIndicator()),
QueryError(:final error) => Center(child: Text('No devices: $error')),
// Data on screen: keep it, and say quietly that it is being refreshed.
QuerySuccess(:final data, :final isRefetching) => Column(
children: <Widget>[
if (isRefetching) const LinearProgressIndicator(minHeight: 2),
Expanded(
child: ListView(
children: <Widget>[
for (final device in data) DeviceTile(device),
],
),
),
],
),
};

A failed refresh does not throw the data away either: the result is a QueryError whose staleData still holds the list, and hasStaleData says so. Whether to show the rows with a warning or only the error is the screen's decision; see queries.

One query's indicator, in each call style​

The same header — "Devices", and a small spinner while the list refreshes — in the four ways to read a query. They are equal: pick the one your widget is already written in. See four ways to read a query.

class DevicesHeader extends StatelessWidget {
const DevicesHeader({super.key});


Widget build(BuildContext context) {
final devices = context.query(DeviceQueries.list());
return ListTile(
title: const Text('Devices'),
trailing: devices.isRefetching ? const RefreshingSpinner() : null,
);
}
}

The header and the list body can read the same key in two widgets. They share one cache entry and one request; each widget rebuilds for its own read.

Every query: a global progress bar​

A bar that shows while anything loads is not about one query, so it does not read one. IsFetchingController counts the queries whose fetchStatus is fetching right now, as a ValueListenable<int>:

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


State<FetchingBar> createState() => _FetchingBarState();
}

class _FetchingBarState extends State<FetchingBar> {
late final IsFetchingController _fetching =
IsFetchingController(QueryClientProvider.read(context));


void dispose() {
_fetching.dispose();
super.dispose();
}


Widget build(BuildContext context) => ValueListenableBuilder<int>(
valueListenable: _fetching,
builder: (context, count, _) => count == 0
? const SizedBox(height: 2)
: const LinearProgressIndicator(minHeight: 2),
);
}

QueryClientProvider.read looks the client up without subscribing to the provider, which is what a late final field initialiser wants. The controller subscribes to the cache only while something listens to it, and notifies only when the count changes, not for every cache event.

Put it where every screen shows it. Under the app bar's title, it costs no layout: two pixels, empty or filled.

class DevicesScaffold extends StatelessWidget {
const DevicesScaffold({super.key, required this.body});

final Widget body;


Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('My home'),
// Two pixels under the title: empty, or a bar while anything loads.
bottom: const PreferredSize(
preferredSize: Size.fromHeight(2),
child: FetchingBar(),
),
),
body: body,
);
}

A paused fetch — offline under the default network mode — is not counted: nothing is in flight, so a bar that stayed on would lie.

Only some queries​

filters: narrows the count, with the same QueryFilters every bulk operation takes (see filters). A sync badge on the devices tab counts only what lives under ['devices']:

// Every query under ['devices'] — the list, the pages, each detail.
late final IsFetchingController _devicesFetching = IsFetchingController(
QueryClientProvider.read(context),
filters: QueryFilters(queryKey: DeviceKeys.all),
);

The filters are fixed for the controller's life; another set is another controller. Without a widget, client.isFetching(filters: …) is the same count, read once.

Try it: open the screen below, turn on Slow post 3 and press Refetch all. The fetching= count next to the button drops as the fast posts land and stays at one while post 3 is still on its way, and each post's own row says refreshing while its data stays on screen.

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

Mutations in flight​

client.isMutating() is the count of running mutations, read once. To subscribe to it — a "saving…" label — use a MutationStateController filtered on MutationStatus.pending; the length of its list is the count. See mutation state.

In React Query

isFetching and isRefetching are the fields of the same name on useQuery's result, and IsFetchingController is useIsFetching. The count is the same; it is a ValueListenable because a Flutter widget subscribes through one. See differences from TanStack Query.