UI Integration

This guide focuses on the Presentation layer—connecting business logic to Flutter widgets through ViewModels, reactive state management, and event handling. You'll learn how ViewModels transform domain data into UI-ready state, how AsyncBuilder renders asynchronous data without manual state checking, and how to handle one-time events like navigation or snackbars. By the end, you'll understand the unidirectional data flow pattern that makes UI complexity scale linearly with feature complexity rather than exponentially.

The ViewModel Pattern

Role and Responsibilities

In the Chassis architecture, ViewModels serve as the bridge between business logic and the widget tree, as explained in the layered architecture from Core Architecture. Their primary responsibility is state transformation—converting raw domain data into a format the UI can render directly without additional processing. Unlike traditional controllers that might manipulate widgets, ViewModels emit state changes and widgets rebuild reactively in response.

ViewModels manage three distinct concerns. First, they hold the current UI state and notify listeners when it changes through Flutter's ChangeNotifier mechanism. Second, they translate user actions into Commands or Queries and dispatch them through the Mediator. Third, they emit events for one-time occurrences like showing snackbars or navigating, keeping these separate from persistent state.

class UserProfileState {
  const UserProfileState({
    required this.user,
    required this.isEditing,
  });

  final Async<User> user;
  final bool isEditing;

  UserProfileState copyWith({
    Async<User>? user,
    bool? isEditing,
  }) {
    return UserProfileState(
      user: user ?? this.user,
      isEditing: isEditing ?? this.isEditing,
    );
  }

  static UserProfileState initial() {
    return UserProfileState(
      user: Async.loading(),
      isEditing: false,
    );
  }
}

sealed class UserProfileEvent {}

class UserUpdatedEvent implements UserProfileEvent {
  const UserUpdatedEvent();
}

class UserUpdateFailedEvent implements UserProfileEvent {
  const UserUpdateFailedEvent(this.message);
  final String message;
}

class UserProfileViewModel extends ViewModel<UserProfileState, UserProfileEvent> {
  UserProfileViewModel(Mediator mediator)
      : super(mediator, initial: UserProfileState.initial());

  void loadUser(String userId) {
    // Using onState for full lifecycle control
    watch(
      mediator.watchUser(userId: userId),
      onState: (asyncUser) {
        setState(state.copyWith(user: asyncUser));
      },
    );
  }

  void updateEmail(String userId, String newEmail) {
    // Using onData/onError for cleaner event handling
    run(
      mediator.updateUserEmail(userId: userId, newEmail: newEmail),
      onData: (_) => sendEvent(UserUpdatedEvent()),
      onError: (error) => sendEvent(UserUpdateFailedEvent(error.toString())),
    );
  }

  void toggleEditMode() {
    setState(state.copyWith(isEditing: !state.isEditing));
  }
}

State immutability ensures predictable behavior. The copyWith pattern creates new state objects rather than mutating existing ones, which simplifies debugging and prevents subtle bugs from shared mutable state. Local UI state like isEditing lives in the ViewModel, while domain data like user profiles flows through the Mediator from handlers.

Unidirectional Data Flow

Chassis enforces unidirectional data flow where user interactions flow upward as Commands or Queries, and data flows downward as state updates. This pattern prevents bidirectional dependencies that complicate debugging and testing. Widgets never call repositories directly, and repositories never know about ViewModels, creating a clean separation of concerns.

spinner

This pattern creates a predictable loop: interaction → command → handler → repository → state update → widget rebuild. Data flows in one direction, making it easy to trace how user actions affect state and how state changes trigger UI updates. If the UI displays incorrect data, you can trace backward through this flow—check the state, check ViewModel updates, check handler logic, check repository implementation.

Unlike traditional controllers that might manipulate widgets directly, a Chassis ViewModel relies exclusively on state mutation. When a user taps a button, the ViewModel does not modify the view directly. Instead, it dispatches a command to the Mediator and updates its internal state based on the result. The widget observes this state change and rebuilds accordingly.

Lifecycle Methods

ViewModels provide two methods for handling asynchronous operations: run() for futures and watch() for streams. Both methods accept raw Future<T> or Stream<T> values (typically from mediator extension methods) and provide callbacks to handle state updates.

The watch() Method

The watch() method subscribes to a stream, calling the provided callback whenever the stream emits a new value. Subscription management happens automatically—the ViewModel disposes subscriptions when it disposes, preventing memory leaks. Use watch for data that changes over time, like todo lists, presence indicators, or collaborative document state.

The run() Method

The run() method executes a future and handles the result, commonly used for one-time operations like data fetches or commands. Use this for initial data loads, mutations, or any operation that completes once rather than streaming updates.

Callback Patterns

Both run() and watch() support three callback patterns, allowing you to choose the right level of control:

onState - Full lifecycle control with Async<T>:

Use onState when you need to handle the complete async lifecycle in your state, such as showing loading indicators or maintaining previous data during refetches.

onData - Success-only callback:

Use onData when you only care about successful results and want to work with the unwrapped value directly.

onError - Error-only callback:

Use onError for error handling, often combined with onData for clean separation of success and failure cases.

Combined callbacks:

Combine onData and onError when you need different behavior for success and failure but don't need to handle the loading state explicitly.

Modeling State with Async

The Async Type

Async is a sealed class representing the complete lifecycle of an asynchronous operation, ensuring UI handles all possibilities—loading, success, and error. This eliminates bugs where loading states are forgotten or error conditions go unhandled. The sealed class guarantees exhaustive pattern matching, where Dart's type system requires handling all three cases.

AsyncLoading and AsyncError can optionally retain previous data, enabling the UI to show stale data during refetches or display previous values alongside error messages. This anti-flickering capability improves user experience significantly, as discussed in the AsyncBuilder section.

Pattern Matching

Dart 3's pattern matching makes working with Async concise and type-safe. The sealed class ensures exhaustive checking—the compiler requires handling all three cases when switching on Async values.

Pattern matching extracts values safely. The compiler guarantees that value is non-null within the AsyncData case, eliminating defensive null checks. This type safety prevents runtime errors and makes code more maintainable.

Fluent State Transitions

Async provides fluent methods for common state transitions, simplifying how ViewModels update state as operations progress through their lifecycle.

These methods preserve previous data when appropriate, enabling the UI to maintain display during refetches or show stale data with error overlays. This pattern supports the anti-flickering behavior that improves user experience during data refreshes.

AsyncBuilder Widget

Basic Usage

AsyncBuilder is a StatelessWidget that renders different UI based on Async state, eliminating manual state checking in build methods. It takes an Async state and three builders—one for data, one for loading, and one for errors—automatically selecting the appropriate builder based on current state.

The builder callback receives the unwrapped User object. No null checks are required—AsyncBuilder only calls this builder when data is available and successfully unwrapped. Custom loadingBuilder and errorBuilder provide branded loading and error experiences tailored to your application's design.

Anti-Flickering with maintainState

The maintainState parameter, which defaults to true, prevents flickering during refetches by showing previous data during loading states instead of the loading widget. This creates a much smoother user experience, especially for pull-to-refresh scenarios.

Consider the visual flow through different states. On initial load with Async.loading() and no previous data, the loadingBuilder renders showing a spinner. After first load with Async.data(user), the builder renders displaying the user profile. On refetch with Async.loading(previous: user), the builder continues rendering with previous user data—no flicker to loading state. When refetch completes with Async.data(newUser), the builder renders with updated user data, smoothly transitioning from old to new.

This pattern is ideal for pull-to-refresh scenarios where showing stale data during refresh provides better UX than a loading spinner. Users see their data immediately and can continue interacting while fresh data loads in the background. When fresh data arrives, the UI updates smoothly without jarring transitions.

Handling One-Time Events

State vs Events

A fundamental architectural distinction in Chassis is between persistent state and ephemeral events. State represents data that determines what the UI renders at any moment—user profiles, form field values, lists of items, loading indicators. If the user rotates their device or navigates away and back, this state should persist and restore the same display.

Events represent one-time occurrences that should not be replayed if the UI rebuilds. They trigger side effects like navigation, snackbars, dialogs, or vibrations. A "User created successfully" event shows a snackbar once, not every time the widget rebuilds. A "Payment failed" event shows an error dialog once. A "Login succeeded" event navigates to the home screen once.

State determines what appears on screen right now. Events describe what should happen once in response to an action. This separation prevents bugs where snackbars show repeatedly or navigation happens multiple times due to widget rebuilds.

ConsumerMixin Usage

ConsumerMixin provides automatic event subscription management in StatefulWidgets, handling subscription lifecycle through initState and dispose. It ensures subscriptions are created when the widget initializes and cancelled when the widget disposes, preventing memory leaks.

The onEvent method creates a subscription to the ViewModel's event stream, calling the provided callback each time an event is emitted. Subscriptions are stored internally and cancelled automatically when the widget disposes, preventing memory leaks from forgotten subscriptions. Pattern matching on sealed event types ensures exhaustive handling—the compiler requires handling all event types defined in the sealed hierarchy.

Why Not Put Events in State?

A common mistake is modeling events as nullable state properties, such as String? snackbarMessage in the state class. This approach causes several problems that become apparent as applications grow. Rebuilds replay events—if the widget rebuilds for unrelated reasons, the snackbar shows again. Manual cleanup becomes required, forcing you to null out the property after consuming it, creating imperative update patterns that complicate state management. State pollution occurs as ephemeral data clutters the state object with fields that don't represent persistent UI state.

Events solve these problems by firing once per occurrence, regardless of rebuilds. No manual cleanup is required—events are delivered through a stream that widgets subscribe to independently of the widget rebuild cycle. This architectural separation keeps state clean and focused on what should appear on screen, while events handle what should happen once.

Widget Testing

Testing with Mock ViewModel

Widget tests verify UI rendering and user interaction handling without executing business logic, isolating the UI layer from domain concerns. Mock the ViewModel to control state and verify method calls, ensuring widgets respond correctly to different states and user interactions.

Mocking the ViewModel isolates UI tests from business logic. The test verifies rendering and interaction patterns without executing real commands or queries. Use when() to control ViewModel state, creating different scenarios like loading, success, and error states. Use verify() to assert that methods were called with expected parameters, ensuring widgets dispatch correct operations in response to user interactions.

Testing Event Handling

Testing event-driven side effects requires simulating event emission through StreamControllers, allowing you to verify that widgets respond appropriately to ViewModel events.

Simulating events through a StreamController allows testing UI reactions to ViewModel events without executing real business logic. The test verifies that snackbars appear, dialogs open, or navigation occurs in response to events, ensuring the event handling code works correctly.

For testing business logic independently of the UI, see Business Logic.

Summary

The ViewModel pattern bridges business logic and UI through state transformation and command dispatch, following the unidirectional data flow illustrated in this guide. Async models the complete lifecycle of asynchronous operations with exhaustive pattern matching, eliminating bugs from unhandled loading or error states. AsyncBuilder renders Async state automatically with anti-flickering support through the maintainState parameter. Events handle one-time occurrences separately from persistent state using ConsumerMixin for subscription management.

This presentation layer integrates seamlessly with the business logic layer explored in Business Logic and the architectural foundations from Core Architecture. With these patterns, you can build Flutter applications where UI complexity scales linearly with feature complexity, not exponentially. The framework enforces patterns that prevent common mistakes while remaining flexible enough to handle sophisticated requirements.

Last updated