A global error snackbar
A list the user is reading refreshes in the background, and the refresh fails.
The list is still on screen and still correct as far as anyone knows, so the
screen shows it as before — but the user should hear that it may be out of
date. A save that fails behind a closed dialog should be reported too. Doing
that in every screen is repetitive and easy to forget; doing it in a query's
queryFn would report every retry. The caches take one onError each, which
runs once per failure after the retries are spent: the place for a single
toast. The work is in deciding what not to report.
The finished code
What a query or a mutation can tell the handler, through meta:
/// What a query or a mutation tells the app-wide error handler. The library
/// never reads `meta`; this app's handler does.
class ErrorReporting {
/// Toast failures, saying [message] instead of the generic sentence.
const ErrorReporting.toast([this.message]) : show = true;
const ErrorReporting._silent()
: show = false,
message = null;
/// No toast: the screen shows this failure itself.
static const ErrorReporting silent = ErrorReporting._silent();
final bool show;
final String? message;
}
The client, with a handler on each cache:
final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey =
GlobalKey<ScaffoldMessengerState>();
QueryClient createQueryClient() => QueryClient(
queryCache: QueryCache(
onError: (error, _, query) {
// A first load has nothing on screen and shows its own error
// state; a toast is for a refresh of data the user is looking at.
if (!query.state.hasData) return;
_toast(error, query.meta, fallback: 'Could not refresh');
},
),
mutationCache: MutationCache(
onError: (error, _, __, ___, mutation) {
// A form shows its field errors next to the fields.
if (error is ValidationException) return;
// A mutation with an `onError` of its own handles its failures.
if (mutation.options.onError != null) return;
_toast(error, mutation.meta, fallback: 'Could not save');
},
),
);
void _toast(Object error, Object? meta, {required String fallback}) {
final reporting = meta is ErrorReporting ? meta : null;
if (reporting?.show == false) return;
final detail = error is ApiException ? error.message : 'Something went wrong';
scaffoldMessengerKey.currentState
// Ten queries failing together — the network went — say it once.
?..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text('${reporting?.message ?? fallback}: $detail')));
}
And the root widget, which hands the MaterialApp the messenger key:
class CatalogueApp extends StatelessWidget {
const CatalogueApp({super.key, required this.api});
final ProductApi api;
Widget build(BuildContext context) => ProductApiScope(
api: api,
child: QueryClientProvider.create(
create: createQueryClient,
child: MaterialApp(
scaffoldMessengerKey: scaffoldMessengerKey,
home: const ProductListScreen(),
),
),
);
}
How it works
- The caches'
onErrorruns once per failure.QueryCache.onErroris called when a query's fetch has failed for good — after its retries — and not for each attempt. A cancelled fetch — a search the next keystroke replaced, a first load its reader left — is not a failure and does not call it.MutationCache.onErroris the same for a mutation. - A toast needs no
BuildContext. The handler lives in the client, far from any widget. AGlobalKey<ScaffoldMessengerState>given to theMaterialAppreaches its messenger from anywhere, andcurrentStateisnullonly before the app has built — then the toast is skipped. - A first load is not toasted. A query with no data has nothing on
screen; its own error state is the report (the list screen's "Could not load
products").
query.state.hasDatatells the two cases apart. - A form's field errors are not toasted. A
ValidationExceptionis shown next to the fields by the form, so the mutation handler skips it. - A mutation with its own
onErroris not toasted. The handler readsmutation.options.onError: if the mutation's options handle their failures, they know better than a generic sentence. metais the per-query switch. The library carriesmetafrom the options toquery.metaandmutation.metaand never reads it. This app reads it as anErrorReporting:toast('Could not refresh the catalogue')changes the sentence,ErrorReporting.silentturns the toast off.- Ten failures, one toast. When the network goes, every active query
fails at once.
hideCurrentSnackBarbeforeshowSnackBarreplaces the visible toast rather than queueing ten.
A query that reports its own failures opts out:
QueryObserverOptions<List<Product>> quietProductListQuery(ProductApi api) =>
productListQuery(api).copyWith(meta: ErrorReporting.silent);
Try it: "Fetch a missing post" in the demo asks for a post that does not
exist, with a meta that asks for a toast; the cache's handler reads it and
shows the SnackBar. The log panel lists every cache callback as it runs.
Traps
- Queries have no
onErrorof their own. Per-queryonSuccess,onErrorandonSettledare not options; the cache-level callbacks replace them, and a screen reacts to a failure through the result it reads. - The callbacks are constructor arguments. A
QueryCachegets itsonErrorwhen it is built, so the client has to be built with the caches — here increateQueryClient, handed toQueryClientProvider.create. - Pull-to-refresh reports twice. A pulled refresh that fails gets a toast
from here and a banner from the
pull-to-refresh screen. Keep one: the screen's query
can say
meta: ErrorReporting.silent. - Offline is mostly a pause, not a failure. In the default network mode a fetch that starts while the client believes it is offline pauses instead of failing, and a failed attempt waits for the network before its next retry. Going into a tunnel therefore does not produce a toast per query; only a fetch whose retries are spent fails, and is toasted once.
metais typedObject?. Anything can be there; the handler checksmeta is ErrorReportingand treats anything else as "no preference".
Variations
- Report to a crash reporter. The same handler is the place to send
unexpected errors — not
ApiExceptions — to your error-reporting service, with the query's key as context. - A success toast for mutations.
MutationCache(onSuccess: ...)with ametathat carries the sentence ("Saved") gives every mutation that asks for it the same confirmation. - Sign out on 401. When auth is not handled in the transport, the query
handler can check
error is ApiException && error.status == 401and sign out; Auth and token refresh handles it lower down instead.
The same pattern, and the same reasons: new QueryCache({ onError }), checking
query.state.data !== undefined before toasting, and meta to opt out. The
per-query onError was removed from useQuery in v5 for exactly this reason.
See also
- Global callbacks — every cache-level callback and the order they run in.
- Network mode — when a fetch pauses instead of failing.
- Query retries — how long a failure takes to reach the handler.