Sparround

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 NotFoundFailure is 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.

SituationPresentation formWhy
First load failed, no dataFull-screen error plus a retry buttonThere is nothing to show; the only action available is retry
Refresh failed, stale data presentA snackbar or a thin banner; the list stays putClearing stale data would interrupt the user's work
A write failed (submit)A snackbar or a banner above the form; the form stays filled inLosing what the user typed is the worst outcome
Field validation failedA separate message under each fieldThe user must see which field to fix
The session expired (`AuthFailure`)Redirect to login plus a short explanationRetry is pointless — the token must be renewed
No permission (`ForbiddenFailure`)An explanatory screen; do not redirect to loginSigning in again will change nothing
Empty list (no error)"Nothing here" plus a suggested actionThis is a success case — showing an error screen is wrong
dart
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; build runs again on the next read.
  • ref.refresh(provider.future) — rebuilds immediately and lets you await the 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