App Architecture & Design Patterns

State & UI Contracts: View States Pattern

16 min Lesson 7 of 12

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.

Why it matters: When your state type is a sealed class, Dart's exhaustive 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),
          ),
      },
    );
  }
}
Tip: Prefer a typed 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 ViewState subtype 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.
Warning: Avoid creating overly granular states like 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 classLoadingState, 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.