State & UI Contracts: View States Pattern
State & UI Contracts: View States Pattern
Every screen in a Flutter application lives through a lifecycle: it starts by loading data, then either succeeds, encounters an error, or finds nothing to display. When this lifecycle is implicit — buried in boolean flags like isLoading, hasError, and isEmpty — the widget tree becomes brittle and riddled with bugs. The View States Pattern makes this lifecycle explicit by modelling it as a sealed class or enum, creating a typed contract between the ViewModel and the widget.
switch forces you to handle every possible case. You cannot accidentally forget the loading spinner, the empty illustration, or the retry button — the compiler will refuse to build until every branch is covered.Modelling the Screen Lifecycle
A typical data-driven screen passes through four distinct states:
- Loading — an async operation is in progress; show a progress indicator.
- Success — data arrived; render the content.
- Error — the operation failed; show a message and a retry action.
- Empty — the operation succeeded but returned no items; show a helpful illustration.
Encoding these as a sealed class (Dart 3+) gives each variant its own data payload and makes every switch exhaustive at compile time.
Defining a Sealed ViewState
/// The four possible states of any data screen.
sealed class ViewState<T> {
const ViewState();
}
final class LoadingState<T> extends ViewState<T> {
const LoadingState();
}
final class SuccessState<T> extends ViewState<T> {
const SuccessState(this.data);
final T data;
}
final class ErrorState<T> extends ViewState<T> {
const ErrorState(this.message, {this.retry});
final String message;
final VoidCallback? retry;
}
final class EmptyState<T> extends ViewState<T> {
const EmptyState({this.message = 'No items found.'});
final String message;
}
The ViewModel Side
A ChangeNotifier-based ViewModel (or any reactive primitive) holds a single ViewState field and transitions it atomically. This eliminates the classic "three-boolean" anti-pattern where isLoading = true and hasError = true can coexist — an impossible real-world combination that defensive widget code still has to guard against.
ViewModel Emitting Typed ViewStates
class PostsViewModel extends ChangeNotifier {
ViewState<List<Post>> _state = const LoadingState();
ViewState<List<Post>> get state => _state;
Future<void> loadPosts() async {
_state = const LoadingState();
notifyListeners();
try {
final posts = await _repository.fetchPosts();
_state = posts.isEmpty
? const EmptyState(message: 'No posts yet. Check back later!')
: SuccessState(posts);
} catch (e) {
_state = ErrorState(
'Failed to load posts: ${e.toString()}',
retry: loadPosts,
);
}
notifyListeners();
}
}
The Widget Side — Exhaustive Rendering
The widget listens to the ViewModel and uses a switch expression on the ViewState. Because the switch is exhaustive over a sealed class, adding a new variant later will produce a compile-time error in every widget that needs to handle it — a built-in safety net.
Widget Rendering All Four States
class PostsScreen extends StatelessWidget {
const PostsScreen({super.key});
@override
Widget build(BuildContext context) {
final vm = context.watch<PostsViewModel>();
return Scaffold(
appBar: AppBar(title: const Text('Posts')),
body: switch (vm.state) {
LoadingState() => const Center(
child: CircularProgressIndicator(),
),
SuccessState(:final data) => ListView.builder(
itemCount: data.length,
itemBuilder: (_, i) => PostTile(post: data[i]),
),
ErrorState(:final message, :final retry) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
if (retry != null)
ElevatedButton(
onPressed: retry,
child: const Text('Retry'),
),
],
),
),
EmptyState(:final message) => Center(
child: Text(message),
),
},
);
}
}
sealed class over a plain enum when each state needs to carry different data (e.g. SuccessState holds a list while ErrorState holds a message string). Use a plain enum only for stateless state machines where no payload is required.Enum Variant — When Payloads Are Not Needed
For simpler screens where the widget fetches its own data from a single source and payloads live elsewhere, a plain enum is a lighter alternative that still enforces exhaustive handling in modern Dart switch expressions.
Simple Enum ViewState
enum ScreenStatus { loading, success, error, empty }
class SimpleListViewModel extends ChangeNotifier {
ScreenStatus status = ScreenStatus.loading;
List<String> items = [];
String errorMessage = '';
Future<void> load() async {
status = ScreenStatus.loading;
notifyListeners();
try {
items = await _repo.fetchItems();
status = items.isEmpty ? ScreenStatus.empty : ScreenStatus.success;
} catch (e) {
errorMessage = e.toString();
status = ScreenStatus.error;
}
notifyListeners();
}
}
// In the widget:
// switch (vm.status) {
// ScreenStatus.loading => LoadingWidget(),
// ScreenStatus.success => ContentWidget(items: vm.items),
// ScreenStatus.error => ErrorWidget(message: vm.errorMessage),
// ScreenStatus.empty => EmptyWidget(),
// }
Benefits and Trade-offs
Adopting the View States Pattern consistently across a codebase delivers several advantages:
- Compile-time completeness — the Dart compiler catches unhandled states immediately.
- Single source of truth — the UI derives from exactly one variable, eliminating boolean flag conflicts.
- Testability — ViewModel unit tests only need to assert which
ViewStatesubtype is emitted for each scenario. - Readability — a new developer can read the sealed class definition and instantly understand every state the screen can be in.
RefreshingState or PaginatingState before you need them. Start with the four canonical states and introduce new variants only when a real UX requirement demands distinct rendering — premature variants inflate the sealed hierarchy and add switch branches to every widget.Summary
The View States Pattern replaces scattered boolean flags with a single, typed state variable. By modelling a screen's lifecycle as a sealed class — LoadingState, SuccessState, ErrorState, and EmptyState — you create an unambiguous contract between the ViewModel and the widget. The ViewModel transitions atomically through well-defined states; the widget renders each one exhaustively via a switch expression. The result is a UI layer that is impossible to leave in an undefined state, trivial to test, and self-documenting for any developer who reads the sealed class definition.