Empty and error state widgets
Reusable placeholder widgets for empty lists, failures and offline screens with an icon, message and retry action.
FlutterFeedback and empty statesflutteremptystateerrorretry
Code
import 'package:flutter/material.dart';
class StateView extends StatelessWidget {
const StateView({super.key, required this.icon, required this.title, this.message, this.actionLabel, this.onAction});
final IconData icon;
final String title;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
/// Nothing to show yet.
factory StateView.empty({String title = 'Nothing here yet', String? message, String? actionLabel, VoidCallback? onAction}) =>
StateView(icon: Icons.inbox_outlined, title: title, message: message, actionLabel: actionLabel, onAction: onAction);
/// A request failed; maps common exceptions to friendly copy.
factory StateView.error(Object error, {required VoidCallback onRetry}) {
final offline = error.toString().contains('SocketException') || error.toString().contains('ClientException');
return StateView(
icon: offline ? Icons.wifi_off : Icons.error_outline,
title: offline ? 'You are offline' : 'Something went wrong',
message: offline ? 'Check your connection and try again.' : 'Please try again in a moment.',
actionLabel: 'Try again',
onAction: onRetry,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: SingleChildScrollView(
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, textAlign: TextAlign.center),
if (message != null) ...[
const SizedBox(height: 8),
Text(message!, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 20),
FilledButton.tonal(onPressed: onAction, child: Text(actionLabel!)),
],
]),
),
);
}
}
/// Picks the right view for a FutureBuilder/AsyncSnapshot-like result.
class AsyncBody<T> extends StatelessWidget {
const AsyncBody({super.key, required this.snapshot, required this.onRetry, required this.builder, this.isEmpty});
final AsyncSnapshot<T> snapshot;
final VoidCallback onRetry;
final Widget Function(BuildContext, T) builder;
final bool Function(T)? isEmpty;
@override
Widget build(BuildContext context) {
if (snapshot.hasError) return StateView.error(snapshot.error!, onRetry: onRetry);
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
final data = snapshot.data as T;
if (isEmpty?.call(data) ?? false) return StateView.empty();
return builder(context, data);
}
}
How it works
What it solves
Every list or detail screen has the same four states: loading, empty, failed and loaded. Copying slightly different placeholders into each screen produces inconsistent copy and forgotten retry buttons.
How it works
StateView is one centred column with an icon, title, optional message and an optional action. Two named factories cover the common cases: empty for zero results with an optional call to action, and error, which inspects the exception text to decide between an offline message and a generic failure and always offers Try again.
AsyncBody is a small switch over an AsyncSnapshot: errors first, then a spinner while there is no data, then an empty check supplied by the caller, and finally the real builder. The scroll view inside the placeholder prevents overflow on small screens or with large text.
Adapting it
Replace string matching in error with typed exceptions from your network layer. Use illustrations instead of icons if your design has them. Wrap the body in a RefreshIndicator and give the placeholder an AlwaysScrollableScrollPhysics list when pull to refresh should also work on error.
Pitfalls
- Matching
toString()is brittle; it is a convenience for the demo, not a contract. - A refreshing screen that has old data should keep showing it rather than switch to a spinner.
- Do not show raw exception messages to users.
How to use it
Copy to lib/widgets/. No packages.
FutureBuilder<List<Order>>(
future: future,
builder: (_, snap) => AsyncBody(snapshot: snap, onRetry: reload, isEmpty: (l) => l.isEmpty,
builder: (_, orders) => OrderList(orders)),
)
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.