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.
| State | The user sees |
|---|---|
| Loading | A spinner or a skeleton |
| Error | What went wrong, and a way to retry |
| Empty | An explanation, and what to do next |
| Data | The 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 helpful | Helpful |
|---|---|
SocketException: Failed host lookup | You seem to be offline. Check your connection and try again. |
Error 500 | Something went wrong on our side. Please try again in a moment. |
FormatException | We could not read the response. Please update the app. |
Error | Could 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.