Dart Tutorial

Flutter Lesson 48 of 83 4 min read

Loading, Error and Empty States in Flutter

Design robust data screens in Flutter: model loading, error, empty and data states, add retry and pull to refresh, and show skeletons.

On this page

A screen that loads data is always in one of four states. Beginners build only the last one.

StateThe user sees
LoadingA spinner or a skeleton
ErrorWhat went wrong, and a way to retry
EmptyAn explanation, and what to do next
DataThe content

Model the states #

A sealed class makes the four states explicit, and the compiler checks that the UI handles each.

sealed class LoadState<T> {
  const LoadState();
}

class Loading<T> extends LoadState<T> {
  const Loading();
}

class Failed<T> extends LoadState<T> {
  const Failed(this.message);
  final String message;
}

class Loaded<T> extends LoadState<T> {
  const Loaded(this.data);
  final T data;
}

A state holder #

import 'package:flutter/foundation.dart';

class OrdersModel extends ChangeNotifier {
  OrdersModel(this._fetch);

  final Future<List<String>> Function() _fetch;

  LoadState<List<String>> state = const Loading();

  Future<void> load() async {
    // Keep showing existing data during a refresh.
    if (state is! Loaded) {
      state = const Loading();
      notifyListeners();
    }
    try {
      state = Loaded(await _fetch());
    } catch (e) {
      state = const Failed('Could not load your orders. Check your connection.');
    }
    notifyListeners();
  }
}

The screen #

import 'package:flutter/material.dart';

class OrdersPage extends StatefulWidget {
  const OrdersPage({super.key, required this.model});
  final OrdersModel model;

  @override
  State<OrdersPage> createState() => _OrdersPageState();
}

class _OrdersPageState extends State<OrdersPage> {
  @override
  void initState() {
    super.initState();
    widget.model.load();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Orders')),
      body: ListenableBuilder(
        listenable: widget.model,
        builder: (context, child) {
          return switch (widget.model.state) {
            Loading() => const Center(child: CircularProgressIndicator()),
            Failed(:final message) => ErrorView(message: message, onRetry: widget.model.load),
            Loaded(data: []) => const EmptyView(
                icon: Icons.receipt_long,
                title: 'No orders yet',
                subtitle: 'When you place an order, it will appear here.',
              ),
            Loaded(:final data) => RefreshIndicator(
                onRefresh: widget.model.load,
                child: ListView.builder(
                  physics: const AlwaysScrollableScrollPhysics(),
                  itemCount: data.length,
                  itemBuilder: (context, i) => ListTile(title: Text(data[i])),
                ),
              ),
          };
        },
      ),
    );
  }
}

Reusable views #

Write these once and use them on every screen.

class ErrorView extends StatelessWidget {
  const ErrorView({super.key, required this.message, required this.onRetry});

  final String message;
  final VoidCallback onRetry;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(Icons.cloud_off, size: 56, color: Theme.of(context).colorScheme.error),
            const SizedBox(height: 16),
            Text(message, textAlign: TextAlign.center),
            const SizedBox(height: 16),
            FilledButton.icon(
              onPressed: onRetry,
              icon: const Icon(Icons.refresh),
              label: const Text('Try again'),
            ),
          ],
        ),
      ),
    );
  }
}

class EmptyView extends StatelessWidget {
  const EmptyView({super.key, required this.icon, required this.title, this.subtitle});

  final IconData icon;
  final String title;
  final String? subtitle;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(icon, size: 56, color: theme.colorScheme.outline),
            const SizedBox(height: 16),
            Text(title, style: theme.textTheme.titleMedium),
            if (subtitle != null) ...[
              const SizedBox(height: 8),
              Text(subtitle!, textAlign: TextAlign.center),
            ],
          ],
        ),
      ),
    );
  }
}

Pull to refresh #

RefreshIndicator shows its spinner until the future returned by onRefresh completes. Two details:

  • Give the list AlwaysScrollableScrollPhysics, or a short list cannot be pulled.
  • During a refresh, keep the old data on screen. Replacing the list with a full-screen spinner is jarring. The model above does this by skipping the loading state when data is already present.

Error messages people can use #

Not helpfulHelpful
SocketException: Failed host lookupYou seem to be offline. Check your connection and try again.
Error 500Something went wrong on our side. Please try again in a moment.
FormatExceptionWe could not read the response. Please update the app.
ErrorCould not load your orders.

Translate technical exceptions into plain language at the API layer, and log the original for yourself.

Skeletons #

For content with a known shape, grey placeholder boxes feel faster than a spinner, because the layout does not jump when data arrives.

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

  @override
  Widget build(BuildContext context) {
    final color = Theme.of(context).colorScheme.surfaceContainerHighest;
    return ListTile(
      leading: CircleAvatar(backgroundColor: color),
      title: Container(height: 14, color: color),
      subtitle: Container(height: 12, width: 120, color: color),
    );
  }
}

The shimmer and skeletonizer packages add the moving highlight.

Feedback for actions #

Loading a screen is one case. Saving, deleting and sending need feedback too.

  • Show progress in the button that was pressed, and disable it.
  • On success, confirm briefly with a SnackBar, or simply close the screen.
  • On failure, keep the user’s input and say what to do.

With Riverpod or Bloc #

Riverpod’s AsyncValue already is this sealed type, with .when(loading:, error:, data:). In Bloc, the cubit’s state classes play the same role. The principle is identical: one value that says which state the screen is in.

Try it yourself #

Build a notifications screen backed by a fake fetch function that you can switch between four behaviours: return items, return an empty list, throw, and take five seconds. Check that every state looks right and that retry and pull to refresh both work.

Practise in the playground Updated by Santosh Adhikari