Pull to refresh
The user pulls the list down; the spinner should stay until the new data is
there — not vanish at once, not hang when the refresh fails — and a refresh
that fails should leave the products they were looking at on screen, with a
note that they may be out of date. The pull has to work on every state of the
screen, including the spinner of a first load and the "no products yet" of an
empty catalogue, where there is nothing to scroll. The library does most of
this already: a query's refetch returns a future that completes when the
fetch has settled, and a failed refetch keeps the last data as staleData.
The finished code
class ProductListScreen extends StatelessWidget {
const ProductListScreen({super.key});
Widget build(BuildContext context) {
final products =
context.query(productListQuery(ProductApiScope.of(context)));
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: RefreshIndicator(
// Completes when the refetch has settled, success or failure; it
// never throws, so the spinner always goes away.
onRefresh: products.refetch,
child: switch (products) {
QueryPending() => const _Scrollable(
child: Center(child: CircularProgressIndicator()),
),
QuerySuccess(:final data) => ProductListView(data),
// A failed refresh keeps the list on screen, with a banner.
QueryError(:final error, staleData: final data?) =>
ProductListView(data, problem: '$error'),
QueryError(:final error) => _Scrollable(
child: Center(child: Text('Could not load products: $error')),
),
},
),
);
}
}
The states with nothing to scroll are wrapped so the pull still works:
/// A `RefreshIndicator` only works over something that scrolls — also when
/// there is nothing to show yet.
class _Scrollable extends StatelessWidget {
const _Scrollable({required this.child});
final Widget child;
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: SizedBox(height: constraints.maxHeight, child: child),
),
);
}
productListQuery is defined in
List to detail, seeded, and
ProductListView is a ListView.builder of ProductTiles with
AlwaysScrollableScrollPhysics, plus a first row saying "Could not refresh"
when problem is set.
How it works
onRefresh: products.refetchis the whole wiring.RefreshIndicatorwants aFuture<void> Function(), andrefetchis one: it fetches again, stale or not, and completes with the new result once the fetch has settled. The spinner therefore lasts as long as the request.refetchnever throws. A failed fetch completes the future with aQueryErrorresult rather than an error, so the indicator always gets its answer and the spinner always goes away. There is notryto write.- A failed refresh keeps the data. The result is a sealed type: a
QueryErrorthat follows a success carries the last good list asstaleData. The screen matches that case before the plain error, and shows the list with a banner instead of replacing it with an error page. - Every state is scrollable.
RefreshIndicatorlistens to a scrollable below it. The loading spinner and the empty state are put in aSingleChildScrollViewwithAlwaysScrollableScrollPhysics, sized to the viewport by aLayoutBuilder, so a first load that failed can be pulled again. - A pull on a list already being fetched starts over.
refetchcancels a fetch that is running for a query with data and starts a new one (cancelRefetch: true, the default). PasscancelRefetch: falsein a lambda to join the running fetch instead:onRefresh: () => products.refetch(cancelRefetch: false).
Refreshing everything on the screen
When one pull should refresh several queries — a dashboard, or a list and the counts in its header — refetch them through the client, by key prefix:
/// Everything under `products` that is on screen, at once. Unlike an
/// observer's `refetch`, it does not wait for a fetch paused offline.
Future<void> refreshProducts(BuildContext context) =>
QueryClientProvider.read(context).refetchQueries(
filters: QueryFilters(
queryKey: ProductKeys.all,
type: QueryTypeFilter.active,
),
);
type: QueryTypeFilter.active limits it to the queries something on screen
reads; the inactive ones are refetched when a screen reads them again, if they
are stale. Use it as onRefresh: () => refreshProducts(context).
Traps
- The spinner can last longer than the request.
refetchwaits for the fetch to settle, and a failing fetch retries first: with the default three retries and their growing delays, a pull on a dead server spins for about seven seconds before the banner appears. Give the query a shorterretry, or the retry policy from the auth recipe, if that is too long. - Offline,
refetchwaits for the network. A fetch that starts while the client believes it is offline is paused, not failed, and the future waits for it to resume — the spinner stays until the connection is back.refreshProductsabove behaves differently:refetchQueriesdoes not wait for paused fetches, so its spinner ends at once. Pick the one that fits the screen; Network mode says when a fetch pauses. - Two error reports for one failure. With the
global error snackbar installed, a failed refresh
of data on screen shows a toast and this screen's banner. Keep one: mark
the list query silent (
meta: ErrorReporting.silent), or drop the banner. RefreshIndicatorneeds a scrollable directly below it. AColumnwith aListViewinside anExpandedworks; aCenterwith a spinner does not, which is what_Scrollableis for.
Variations
- Another call style. The same screen reads as well through a
QueryBuilder, theQueryMixinmethods or aQueryControllerheld by a view model —refetchis on each of them. A view model that owns aQueryControllerpassescontroller.refetchto the indicator the same way. - An infinite list refreshes the same way; see An infinite list view for how many pages it reloads.
- Cupertino.
CupertinoSliverRefreshControltakes the sameonRefresh.
React Native's RefreshControl is wired the same way: refreshing from
isRefetching and onRefresh={refetch}. Here RefreshIndicator keeps its own
spinner state from the future, so there is no flag to pass.
See also
- Queries — the result types,
staleDataincluded. - Background fetching indicators — showing a refetch that the user did not ask for.
- Filters — what
QueryFilterscan match.