Showing errors on screen: AsyncValue, retry, empty states
The error has travelled through the layers: the service threw a technical exception, the repository turned it into a Failure, the notifier placed it into state. The final step is what appears on screen.
The decisions in this layer are behavioural rather than technical:
- In what form is the error shown: full screen, a banner, a snackbar, red text under a field?
- Is there a retry? (Retrying a
NotFoundFailureis pointless.) - Does stale data stay on screen, or get cleared?
- Does the error block the user's work, or is it just a warning?
One rule is absolute: never show `e.toString()` to the user. A message like "SocketException: Failed host lookup: 'api.example.com'" tells the user nothing while exposing the backend's domain. The message always comes from the Failure kind plus localisation.
A second rule: an empty state is not an error. When the list is empty you show "nothing found"; that is a variant of the AsyncData case and must not be confused with the error screen.
| Situation | Presentation form | Why |
|---|---|---|
| First load failed, no data | Full-screen error plus a retry button | There is nothing to show; the only action available is retry |
| Refresh failed, stale data present | A snackbar or a thin banner; the list stays put | Clearing stale data would interrupt the user's work |
| A write failed (submit) | A snackbar or a banner above the form; the form stays filled in | Losing what the user typed is the worst outcome |
| Field validation failed | A separate message under each field | The user must see which field to fix |
| The session expired (`AuthFailure`) | Redirect to login plus a short explanation | Retry is pointless — the token must be renewed |
| No permission (`ForbiddenFailure`) | An explanatory screen; do not redirect to login | Signing in again will change nothing |
| Empty list (no error) | "Nothing here" plus a suggested action | This is a success case — showing an error screen is wrong |
class OrdersPage extends ConsumerWidget {
const OrdersPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final state = ref.watch(orderListNotifierProvider);
// Yan effekt: sessiya bitdikdə yönəltmə. `ref.listen` `build` içində
// təhlükəsizdir və yalnız DƏYİŞİKLİKdə işləyir (hər rebuild-də deyil).
ref.listen(orderListNotifierProvider, (previous, next) {
if (next case AsyncError(error: AuthFailure())) {
context.go('/login?reason=expired');
}
});
return switch (state) {
// 1) Məlumat var, siyahı boş → BOŞ HAL (xəta deyil)
AsyncData(:final value) when value.orders.isEmpty => EmptyState(
title: l10n.ordersEmptyTitle,
message: l10n.ordersEmptyMessage,
actionLabel: l10n.ordersEmptyAction,
onAction: () => context.go('/catalog'),
),
// 2) Məlumat var → siyahı; yenilənmə xətası varsa nazik banner
AsyncData(:final value) => Column(
children: [
// `hasError` + `hasValue`: yenilənmə uğursuz oldu,
// lakin köhnə məlumat qaldı.
if (state.hasError)
ErrorBanner(
message: l10n.refreshFailed,
onRetry: () => ref
.read(orderListNotifierProvider.notifier)
.refresh(),
),
Expanded(child: OrderListView(orders: value.orders)),
],
),
// 3) Məlumat yoxdur, xəta var → TAM EKRAN
AsyncError(:final error) => _fullScreenError(context, ref, error, l10n),
// 4) İlk yükləmə
_ => const Center(child: CircularProgressIndicator()),
};
}
Widget _fullScreenError(
BuildContext context,
WidgetRef ref,
Object error,
AppLocalizations l10n,
) {
// `Failure` növünə görə mesaj və retry qərarı.
final presentation = error is Failure
? presentFailure(error, l10n)
// Failure deyilsə — bu, gözlənilməyən haldır (baq).
// İstifadəçiyə ümumi mesaj, loqa isə tam məlumat.
: ErrorPresentation(message: l10n.errorGeneric, canRetry: true);
return ErrorView(
message: presentation.message,
onRetry: presentation.canRetry
? () => ref.invalidate(orderListNotifierProvider)
: null, // retry mənasızsa düymə YOXDUR
);
}
}Every case handled in one place. Note that `AsyncError` is handled two ways — a banner when data exists, a full screen when it does not.
Making retry actually work. A screen with a retry button that does nothing is the worst variant — the user gets the same result repeatedly without knowing why.
Riverpod offers two mechanisms:
ref.invalidate(provider)— marks the provider invalid;buildruns again on the next read.ref.refresh(provider.future)— rebuilds immediately and lets youawaitthe outcome (handy for pull-to-refresh).
An important nuance: retry only makes sense when the cause is transient. NetworkFailure, TimeoutFailure, ServerFailure — yes. NotFoundFailure, ForbiddenFailure, ValidationFailure — no; showing the button there misleads the user.
A Riverpod 3 addition: when a provider's initialisation fails there is an automatic retry with exponential backoff. That does not replace a manual retry, but for transient network trouble the user may never see anything at all.
Which loading indicator. A centred spinner is fine for the first load. For a refresh, a thin indicator over the existing data is better — and since AsyncValue retains the previous value, that comes naturally. A skeleton (shimmer) only makes sense for the first load.
The most frequently missed case: an empty list. In most projects there is a single branch for AsyncData, and an empty list renders as a blank page. The user concludes the app is broken.
Every list screen should handle four cases: loading, empty, data, error. A fifth — "data present, refresh failed" — is a bonus, but a useful one on most screens.
Practice. Build one screen for all four cases and verify each with your own eyes. The easiest way is the …Local implementation from the previous stage: return an empty list, throw an error, add latency.
Done means: all four states appear on a real screen, the retry button genuinely refreshes, and e.toString() is never displayed.
📚 Sources and documentation
- Error handling with Result objectsofficialdocs.flutter.dev
How an error reaches the view from the view model and how it is handled there.
- Riverpod: what's new (3.0)officialriverpod.dev
Sealed `AsyncValue`, the `valueOrNull` → `value` rename, and the automatic retry mechanism.
- Flutter: internationalizationofficialdocs.flutter.dev
Keeping error messages in `AppLocalizations`.