UI layer case study
                  The UI layer of each feature in your Flutter application should be
                  made up of two components: a View
                   and
                  a ViewModel.
                
                  
                
                  In the most general sense, view models manage UI state,
                  and views display UI state.
                  Views and view models have a one-to-one relationship;
                  for each view, there's exactly one corresponding view model that
                  manages that view's state.
                  Each pair of view and view model make up the UI for a single feature.
                  For example, an app might have classes called
                  LogOutView and a LogOutViewModel.
                
Define a view model
#A view model is a Dart class responsible for handling UI logic. View models take domain data models as input and expose that data as UI state to their corresponding views. They encapsulate logic that the view can attach to event handlers, like button presses, and manage sending these events to the data layer of the app, where data changes happen.
                  The following code snippet is a class declaration for
                  a view model class called the HomeViewModel.
                  Its inputs are the repositories
                   that provide its data.
                  In this case,
                  the view model is dependent on the
                  BookingRepositoryand UserRepository as arguments.
                
class HomeViewModel {
  HomeViewModel({
    required BookingRepository bookingRepository,
    required UserRepository userRepository,
  }) :
    // Repositories are manually assigned because they're private members.
    _bookingRepository = bookingRepository,
    _userRepository = userRepository;
  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;
  // ...
}
                    
                    
                    
                  View models are always dependent on data repositories, which are provided as arguments to the view model's constructor. view models and repositories have a many-to-many relationship, and most view models will depend on multiple repositories.
                  As in the earlier HomeViewModel example declaration,
                  repositories should be private members on the view model,
                  otherwise views would have direct access to
                  the data layer of the application.
                
UI state
#The output of a view model is data that a view needs to render, generally referred to as UI State, or just state. UI state is an immutable snapshot of data that is required to fully render a view.
                  
                
                  The view model exposes state as public members.
                  On the view model in the following code example,
                  the exposed data is a User object,
                  as well as the user's saved itineraries which
                  are exposed as an object of type List<BookingSummary>.
                
class HomeViewModel {
  HomeViewModel({
   required BookingRepository bookingRepository,
   required UserRepository userRepository,
  }) : _bookingRepository = bookingRepository,
      _userRepository = userRepository;
  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;
  User? _user;
  User? get user => _user;
  List<BookingSummary> _bookings = [];
  /// Items in an [UnmodifiableListView] can't be directly modified,
  /// but changes in the source list can be modified. Since _bookings
  /// is private and bookings is not, the view has no way to modify the
  /// list directly.
  UnmodifiableListView<BookingSummary> get bookings => UnmodifiableListView(_bookings);
  // ...
}
                    
                    
                    
                  As mentioned, the UI state should be immutable. This is a crucial part of bug-free software.
                  The compass app uses the package:freezed to
                  enforce immutability on data classes. For example,
                  the following code shows the User class definition.
                  freezed provides deep immutability,
                  and generates the implementation for useful methods like
                  copyWith and toJson.
                
@freezed
class User with _$User {
  const factory User({
    /// The user's name.
    required String name,
    /// The user's picture URL.
    required String picture,
  }) = _User;
  factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
                    
                    
                    
                  Updating UI state
#
                  In addition to storing state,
                  view models need to tell Flutter to re-render views when
                  the data layer provides a new state.
                  In the Compass app, view models extend ChangeNotifier
                   to achieve this.
                
class HomeViewModel extends ChangeNotifier {
  HomeViewModel({
   required BookingRepository bookingRepository,
   required UserRepository userRepository,
  }) : _bookingRepository = bookingRepository,
      _userRepository = userRepository;
  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;
  User? _user;
  User? get user => _user;
  List<BookingSummary> _bookings = [];
  List<BookingSummary> get bookings => _bookings;
  // ...
}
                    
                    
                    
                  
                  HomeViewModel.user is a public member that the view depends on.
                  When new data flows from the data layer and
                  new state needs to be emitted, notifyListeners
                   is called.
                
                    
                  
                        
- New state is provided to the view model from a Repository.
 - The view model updates its UI state to reflect the new data.
 ViewModel.notifyListenersis called, alerting the View of new UI State.- The view (widget) re-renders.
 
                  For example, when the user navigates to the Home screen and the view model is
                  created, the _load method is called.
                  Until this method completes, the UI state is empty,
                  the view displays a loading indicator.
                  When the _load method completes, if it's successful,
                  there's new data in the view model, and it must
                  notify the view that new data is available.
                
class HomeViewModel extends ChangeNotifier {
  // ...
 Future<Result> _load() async {
    try {
      final userResult = await _userRepository.getUser();
      switch (userResult) {
        case Ok<User>():
          _user = userResult.value;
          _log.fine('Loaded user');
        case Error<User>():
          _log.warning('Failed to load user', userResult.error);
      }
      // ...
      return userResult;
    } finally {
      notifyListeners();
    }
  }
}
                    
                    
                    
                  Define a view
#
                  A view is a widget within your app.
                  Often, a view represents one screen in your app that
                  has its own route and includes a Scaffold
                   at the top of the
                  widget subtree, such as the HomeScreen, but this isn't always the case.
                
                  Sometimes a view is a single UI element that
                  encapsulates functionality that needs to be re-used throughout the app.
                  For example, the Compass app has a view called LogoutButton,
                  which can be dropped anywhere in the widget tree that a user might
                  expect to find a logout button.
                  The LogoutButton view has its own view model called LogoutViewModel.
                  And on larger screens, there might be multiple views on screen that
                  would take up the full screen on mobile.
                
The widgets within a view have three responsibilities:
- They display the data properties from the view model.
 - They listen for updates from the view model and re-render when new data is available.
 - They attach callbacks from the view model to event handlers, if applicable.
 
                  
                
                  Continuing the Home feature example,
                  the following code shows the definition of the HomeScreen view.
                
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key, required this.viewModel});
  final HomeViewModel viewModel;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
    );
  }
}
                    
                    
                    
                  
                  Most of the time, a view's only inputs should be a key,
                  which all Flutter widgets take as an optional argument,
                  and the view's corresponding view model.
                
Display UI data in a view
#
                  A view depends on a view model for its state. In the Compass app,
                  the view model is passed in as an argument in the view's constructor.
                  The following example code snippet is from the HomeScreen widget.
                
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key, required this.viewModel});
  final HomeViewModel viewModel;
  @override
  Widget build(BuildContext context) {
    // ...
  }
}
                    
                    
                    
                  
                  Within the widget, you can access the passed-in bookings from the viewModel.
                  In the following code,
                  the booking property is being provided to a sub-widget.
                
@override
  Widget build(BuildContext context) {
    return Scaffold(
      // Some code was removed for brevity.
      body: SafeArea(
        child: ListenableBuilder(
          listenable: viewModel,
          builder: (context, _) {
            return CustomScrollView(
              slivers: [
                SliverToBoxAdapter(...),
                SliverList.builder(
                   itemCount: viewModel.bookings.length,
                    itemBuilder: (_, index) => _Booking(
                      key: ValueKey(viewModel.bookings[index].id),
                      booking:viewModel.bookings[index],
                      onTap: () => context.push(Routes.bookingWithId(
                         viewModel.bookings[index].id)),
                      onDismissed: (_) => viewModel.deleteBooking.execute(
                           viewModel.bookings[index].id,
                         ),
                    ),
                ),
              ],
            );
          },
        ),
      ),
                    
                    
                    
                  Update the UI
#
                  The HomeScreen widget listens for updates from the view model with
                  the ListenableBuilder
                   widget.
                  Everything in the widget subtree under the ListenableBuilder widget
                  re-renders when the provided Listenable
                   changes.
                  In this case, the provided Listenable is the view model.
                  Recall that the view model is of type ChangeNotifier
                  
                  which is a subtype of the Listenable type.
                
@override
Widget build(BuildContext context) {
  return Scaffold(
    // Some code was removed for brevity.
      body: SafeArea(
        child: ListenableBuilder(
          listenable: viewModel,
          builder: (context, _) {
            return CustomScrollView(
              slivers: [
                SliverToBoxAdapter(),
                SliverList.builder(
                  itemCount: viewModel.bookings.length,
                  itemBuilder: (_, index) =>
                      _Booking(
                        key: ValueKey(viewModel.bookings[index].id),
                        booking: viewModel.bookings[index],
                        onTap: () =>
                            context.push(Routes.bookingWithId(
                                viewModel.bookings[index].id)
                            ),
                        onDismissed: (_) =>
                            viewModel.deleteBooking.execute(
                              viewModel.bookings[index].id,
                            ),
                      ),
                ),
              ],
            );
          }
        )
      )
  );
}
                    
                    
                    
                  Handling user events
#Finally, a view needs to listen for events from users, so the view model can handle those events. This is achieved by exposing a callback method on the view model class which encapsulates all the logic.
                  
                
                  On the HomeScreen, users can delete previously booked events by swiping
                  a Dismissible
                   widget.
                
Recall this code from the previous snippet:
SliverList.builder(
  itemCount: widget.viewModel.bookings.length,
  itemBuilder: (_, index) => _Booking(
    key: ValueKey(viewModel.bookings[index].id),
    booking: viewModel.bookings[index],
    onTap: () => context.push(
      Routes.bookingWithId(viewModel.bookings[index].id)
    ),
    onDismissed: (_) =>
      viewModel.deleteBooking.execute(widget.viewModel.bookings[index].id),
  ),
),
     
                  On the HomeScreen, a user's saved trip is represented by
                  the _Booking widget. When a _Booking is dismissed,
                  the viewModel.deleteBooking method is executed.
                
                  A saved booking is application state that persists beyond
                  a session or the lifetime of a view,
                  and only repositories should modify such application state.
                  So, the HomeViewModel.deleteBooking method turns around and
                  calls a method exposed by a repository in the data layer,
                  as shown in the following code snippet.
                
Future<Result<void>> _deleteBooking(int id) async {
  try {
    final resultDelete = await _bookingRepository.delete(id);
    switch (resultDelete) {
      case Ok<void>():
        _log.fine('Deleted booking $id');
      case Error<void>():
        _log.warning('Failed to delete booking $id', resultDelete.error);
        return resultDelete;
    }
    // Some code was omitted for brevity.
    // final  resultLoadBookings = ...;
    return resultLoadBookings;
  } finally {
    notifyListeners();
  }
}
                    
                    
                    
                  In the Compass app, these methods that handle user events are called commands.
Command objects
#
                  Commands are responsible for the interaction that starts in the UI layer and
                  flows back to the data layer. In this app specifically,
                  a Command is also a type that helps update the UI safely,
                  regardless of the response time or contents.
                
                  The Command class wraps a method and
                  helps handle the different states of that method,
                  such as running, complete, and error.
                  These states make it easy to display different UI,
                  like loading indicators when Command.running is true.
                
                  The following is code from the Command class.
                  Some code has been omitted for demo purposes.
                
abstract class Command<T> extends ChangeNotifier {
  Command();
  bool running = false;
  Result<T>? _result;
  /// true if action completed with error
  bool get error => _result is Error;
  /// true if action completed successfully
  bool get completed => _result is Ok;
  /// Internal execute implementation
  Future<void> _execute(action) async {
    if (_running) return;
    // Emit running state - e.g. button shows loading state
    _running = true;
    _result = null;
    notifyListeners();
    try {
      _result = await action();
    } finally {
      _running = false;
      notifyListeners();
    }
  }
}
                    
                    
                    
                  
                  The Command class itself extends ChangeNotifier,
                  and within the method Command.execute,
                  notifyListeners is called multiple times.
                  This allows the view to handle different states with very little logic,
                  which you'll see an example of later on this page.
                
                  You may have also noticed that Command is an abstract class.
                  It's implemented by concrete classes such as Command0 Command1.
                  The integer in the class name refers to
                  the number of arguments that the underlying method expects.
                  You can see examples of these implementation classes in
                  the Compass app's utils directory.
                
Ensuring views can render before data exists
#In view model classes, commands are created in the constructor.
class HomeViewModel extends ChangeNotifier {
  HomeViewModel({
   required BookingRepository bookingRepository,
   required UserRepository userRepository,
  }) : _bookingRepository = bookingRepository,
      _userRepository = userRepository {
    // Load required data when this screen is built.
    load = Command0(_load)..execute();
    deleteBooking = Command1(_deleteBooking);
  }
  final BookingRepository _bookingRepository;
  final UserRepository _userRepository;
  late Command0 load;
  late Command1<void, int> deleteBooking;
  User? _user;
  User? get user => _user;
  List<BookingSummary> _bookings = [];
  List<BookingSummary> get bookings => _bookings;
  Future<Result> _load() async {
    // ...
  }
  Future<Result<void>> _deleteBooking(int id) async {
    // ...
  }
  // ...
}
                    
                    
                    
                  
                  The Command.execute method is asynchronous,
                  so it can't guarantee that the data will be available when
                  the view wants to render. This gets at why the Compass app uses Commands.
                  In the view's Widget.build method,
                  the command is used to conditionally render different widgets.
                
// ...
child: ListenableBuilder(
  listenable: viewModel.load,
  builder: (context, child) {
    if (viewModel.load.running) {
      return const Center(child: CircularProgressIndicator());
    }
    if (viewModel.load.error) {
      return ErrorIndicator(
        title: AppLocalization.of(context).errorWhileLoadingHome,
        label: AppLocalization.of(context).tryAgain,
          onPressed: viewModel.load.execute,
        );
     }
    // The command has completed without error.
    // Return the main view widget.
    return child!;
  },
),
// ...
                    
                    
                    
                  
                  Because the load command is a property that exists on
                  the view model rather than something ephemeral,
                  it doesn't matter when the load method is called or when it resolves.
                  For example, if the load command resolves before
                  the HomeScreen widget was even created,
                  it isn't a problem because the Command object still exists,
                  and exposes the correct state.
                
                  This pattern standardizes how common UI problems are solved in the app,
                  making your codebase less error-prone and more scalable,
                  but it's not a pattern that every app will want to implement.
                  Whether you want to use it is highly dependent on
                  other architectural choices you make.
                  Many libraries that help you manage state have
                  their own tools to solve these problems.
                  For example, if you were to use
                  streams
                   and StreamBuilders
                   in your app,
                  the AsyncSnapshot
                   classes provided by Flutter have
                  this functionality built in.
                
Feedback
#As this section of the website is evolving, we welcome your feedback!
Unless stated otherwise, the documentation on this site reflects Flutter 3.35.5. Page last updated on 2025-10-28. View source or report an issue.