Architecture: Building the UI Layer in Flutter


Introduction
Introduction
Introduction
Introduction
Building great UI in Flutter isn’t just about placing widgets on the screen - it’s about creating a structure that’s clean, testable, and easy to manage. That’s where a well-designed UI layer comes into play.
In Flutter’s layered architecture, the UI layer focuses on what the user sees and interacts with while delegating logic to the ViewModel. This separation keeps your code organized and makes updates less painful as your app grows. You don’t want business logic tangled up with UI code.
In this blog, we’ll walk through the key steps to build a solid UI layer, using ViewModels, managing state, and improving testability.
The UI layer of each feature in your Flutter application should be made up of two components: a View
and a ViewModel
.
Building great UI in Flutter isn’t just about placing widgets on the screen - it’s about creating a structure that’s clean, testable, and easy to manage. That’s where a well-designed UI layer comes into play.
In Flutter’s layered architecture, the UI layer focuses on what the user sees and interacts with while delegating logic to the ViewModel. This separation keeps your code organized and makes updates less painful as your app grows. You don’t want business logic tangled up with UI code.
In this blog, we’ll walk through the key steps to build a solid UI layer, using ViewModels, managing state, and improving testability.
The UI layer of each feature in your Flutter application should be made up of two components: a View
and a ViewModel
.
Building great UI in Flutter isn’t just about placing widgets on the screen - it’s about creating a structure that’s clean, testable, and easy to manage. That’s where a well-designed UI layer comes into play.
In Flutter’s layered architecture, the UI layer focuses on what the user sees and interacts with while delegating logic to the ViewModel. This separation keeps your code organized and makes updates less painful as your app grows. You don’t want business logic tangled up with UI code.
In this blog, we’ll walk through the key steps to build a solid UI layer, using ViewModels, managing state, and improving testability.
The UI layer of each feature in your Flutter application should be made up of two components: a View
and a ViewModel
.
Building great UI in Flutter isn’t just about placing widgets on the screen - it’s about creating a structure that’s clean, testable, and easy to manage. That’s where a well-designed UI layer comes into play.
In Flutter’s layered architecture, the UI layer focuses on what the user sees and interacts with while delegating logic to the ViewModel. This separation keeps your code organized and makes updates less painful as your app grows. You don’t want business logic tangled up with UI code.
In this blog, we’ll walk through the key steps to build a solid UI layer, using ViewModels, managing state, and improving testability.
The UI layer of each feature in your Flutter application should be made up of two components: a View
and a ViewModel
.
Define a View Model
Define a View Model
Define a View Model
Define a View Model
In Flutter’s layered architecture, each feature in your app should consist of two key components: a View and its corresponding ViewModel.
At a high level, the ViewModel is responsible for managing the UI state, while the View is responsible for displaying that state. This pairing ensures a clean separation of concerns. Every View has exactly one associated ViewModel, forming a one-to-one relationship. Together, they define the UI behavior for a single feature, like a LoginView
working with a LoginViewModel
.

What is a ViewModel?
A ViewModel is a plain Dart class that handles the presentation logic for the UI. It takes in data from the domain or data layer, often through repositories, and transforms that data into a format the UI can consume.
In addition to exposing the UI state, ViewModels also handle user interactions by providing methods that the View can call, like handling button taps or triggering data refreshes. This keeps logic out of the widget tree, making it easier to test and maintain.
Example: HomeViewModel
Here’s a simplified example of a ViewModel named HomeViewModel
, which depends on two repositories - BookingRepository
and UserRepository
. These repositories provide the necessary data for the UI.
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// ViewModel logic here (e.g., fetch bookings, update user info)
}
In this example:
The repositories are passed as constructor arguments, making the ViewModel flexible and testable.
They are stored as private members, preventing the UI layer from directly accessing the data layer.
The ViewModel is now the single source of truth for the UI’s logic and state handling.
Why Keep Repositories Private?
Making repositories private ensures that the View does not bypass the ViewModel and directly access the data layer. This enforces separation of concerns and makes the codebase more maintainable. ViewModels often depend on multiple repositories, and it's common for them to combine, transform, or format this data before exposing it to the UI.
In Flutter’s layered architecture, each feature in your app should consist of two key components: a View and its corresponding ViewModel.
At a high level, the ViewModel is responsible for managing the UI state, while the View is responsible for displaying that state. This pairing ensures a clean separation of concerns. Every View has exactly one associated ViewModel, forming a one-to-one relationship. Together, they define the UI behavior for a single feature, like a LoginView
working with a LoginViewModel
.

What is a ViewModel?
A ViewModel is a plain Dart class that handles the presentation logic for the UI. It takes in data from the domain or data layer, often through repositories, and transforms that data into a format the UI can consume.
In addition to exposing the UI state, ViewModels also handle user interactions by providing methods that the View can call, like handling button taps or triggering data refreshes. This keeps logic out of the widget tree, making it easier to test and maintain.
Example: HomeViewModel
Here’s a simplified example of a ViewModel named HomeViewModel
, which depends on two repositories - BookingRepository
and UserRepository
. These repositories provide the necessary data for the UI.
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// ViewModel logic here (e.g., fetch bookings, update user info)
}
In this example:
The repositories are passed as constructor arguments, making the ViewModel flexible and testable.
They are stored as private members, preventing the UI layer from directly accessing the data layer.
The ViewModel is now the single source of truth for the UI’s logic and state handling.
Why Keep Repositories Private?
Making repositories private ensures that the View does not bypass the ViewModel and directly access the data layer. This enforces separation of concerns and makes the codebase more maintainable. ViewModels often depend on multiple repositories, and it's common for them to combine, transform, or format this data before exposing it to the UI.
In Flutter’s layered architecture, each feature in your app should consist of two key components: a View and its corresponding ViewModel.
At a high level, the ViewModel is responsible for managing the UI state, while the View is responsible for displaying that state. This pairing ensures a clean separation of concerns. Every View has exactly one associated ViewModel, forming a one-to-one relationship. Together, they define the UI behavior for a single feature, like a LoginView
working with a LoginViewModel
.

What is a ViewModel?
A ViewModel is a plain Dart class that handles the presentation logic for the UI. It takes in data from the domain or data layer, often through repositories, and transforms that data into a format the UI can consume.
In addition to exposing the UI state, ViewModels also handle user interactions by providing methods that the View can call, like handling button taps or triggering data refreshes. This keeps logic out of the widget tree, making it easier to test and maintain.
Example: HomeViewModel
Here’s a simplified example of a ViewModel named HomeViewModel
, which depends on two repositories - BookingRepository
and UserRepository
. These repositories provide the necessary data for the UI.
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// ViewModel logic here (e.g., fetch bookings, update user info)
}
In this example:
The repositories are passed as constructor arguments, making the ViewModel flexible and testable.
They are stored as private members, preventing the UI layer from directly accessing the data layer.
The ViewModel is now the single source of truth for the UI’s logic and state handling.
Why Keep Repositories Private?
Making repositories private ensures that the View does not bypass the ViewModel and directly access the data layer. This enforces separation of concerns and makes the codebase more maintainable. ViewModels often depend on multiple repositories, and it's common for them to combine, transform, or format this data before exposing it to the UI.
In Flutter’s layered architecture, each feature in your app should consist of two key components: a View and its corresponding ViewModel.
At a high level, the ViewModel is responsible for managing the UI state, while the View is responsible for displaying that state. This pairing ensures a clean separation of concerns. Every View has exactly one associated ViewModel, forming a one-to-one relationship. Together, they define the UI behavior for a single feature, like a LoginView
working with a LoginViewModel
.

What is a ViewModel?
A ViewModel is a plain Dart class that handles the presentation logic for the UI. It takes in data from the domain or data layer, often through repositories, and transforms that data into a format the UI can consume.
In addition to exposing the UI state, ViewModels also handle user interactions by providing methods that the View can call, like handling button taps or triggering data refreshes. This keeps logic out of the widget tree, making it easier to test and maintain.
Example: HomeViewModel
Here’s a simplified example of a ViewModel named HomeViewModel
, which depends on two repositories - BookingRepository
and UserRepository
. These repositories provide the necessary data for the UI.
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// ViewModel logic here (e.g., fetch bookings, update user info)
}
In this example:
The repositories are passed as constructor arguments, making the ViewModel flexible and testable.
They are stored as private members, preventing the UI layer from directly accessing the data layer.
The ViewModel is now the single source of truth for the UI’s logic and state handling.
Why Keep Repositories Private?
Making repositories private ensures that the View does not bypass the ViewModel and directly access the data layer. This enforces separation of concerns and makes the codebase more maintainable. ViewModels often depend on multiple repositories, and it's common for them to combine, transform, or format this data before exposing it to the UI.
Structure the UI State
Structure the UI State
Structure the UI State
Structure the UI State
At the heart of any Flutter screen is its UI state—the data needed to render what users see. In a well-architected app, this state comes from the ViewModel, which exposes it in a way that the view (i.e., the UI) can consume without worrying about where the data comes from or how it's processed.

What Is UI State?
UI state is an immutable snapshot of data needed to fully render a screen. Think of it as the "current frame" of what the user should see—this includes content (like a list of bookings), metadata (like a loading flag), or user-related data (like a profile picture).
The ViewModel is responsible for preparing and exposing this state. It’s designed to keep this state safe and predictable by using read-only access in the UI layer.
Example: Home Screen State
Consider the following HomeViewModel
In a travel booking app. It provides access to two essential pieces of data:
A
User
object representing the current userA list of saved itineraries (
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 = [];
/// Exposing an unmodifiable list for safety
UnmodifiableListView<BookingSummary> get bookings =>
UnmodifiableListView(_bookings);
}
User? get user
allows the UI to read the current user.
bookings
Returns a read-only view of the list so that the UI can’t modify the source directly, reducing the risk of side effects.
This pattern helps protect the integrity of the state and aligns with Flutter’s declarative UI principles.
Enforcing Immutability with Freezing
To further safeguard against bugs, you should ensure your state models are immutable. Flutter developers often use the freezed
to enforce immutability and generate helpful utilities like copyWith
, ==
, and toJson
.
Here’s an example of a deeply immutable User
class using freezed
:
@freezed
class User with _$User {
const factory User({
required String name,
required String picture,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
Why is this important?
You can safely pass instances
User
across the app without worrying they’ll be changed unexpectedly.It helps keep state transitions clear and predictable.
By clearly defining and exposing only the necessary parts of your UI state, and ensuring that the state is immutable, you gain better control over how your UI behaves and reacts to changes. This reduces bugs, simplifies testing, and keeps your widget tree lean and clean.
At the heart of any Flutter screen is its UI state—the data needed to render what users see. In a well-architected app, this state comes from the ViewModel, which exposes it in a way that the view (i.e., the UI) can consume without worrying about where the data comes from or how it's processed.

What Is UI State?
UI state is an immutable snapshot of data needed to fully render a screen. Think of it as the "current frame" of what the user should see—this includes content (like a list of bookings), metadata (like a loading flag), or user-related data (like a profile picture).
The ViewModel is responsible for preparing and exposing this state. It’s designed to keep this state safe and predictable by using read-only access in the UI layer.
Example: Home Screen State
Consider the following HomeViewModel
In a travel booking app. It provides access to two essential pieces of data:
A
User
object representing the current userA list of saved itineraries (
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 = [];
/// Exposing an unmodifiable list for safety
UnmodifiableListView<BookingSummary> get bookings =>
UnmodifiableListView(_bookings);
}
User? get user
allows the UI to read the current user.
bookings
Returns a read-only view of the list so that the UI can’t modify the source directly, reducing the risk of side effects.
This pattern helps protect the integrity of the state and aligns with Flutter’s declarative UI principles.
Enforcing Immutability with Freezing
To further safeguard against bugs, you should ensure your state models are immutable. Flutter developers often use the freezed
to enforce immutability and generate helpful utilities like copyWith
, ==
, and toJson
.
Here’s an example of a deeply immutable User
class using freezed
:
@freezed
class User with _$User {
const factory User({
required String name,
required String picture,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
Why is this important?
You can safely pass instances
User
across the app without worrying they’ll be changed unexpectedly.It helps keep state transitions clear and predictable.
By clearly defining and exposing only the necessary parts of your UI state, and ensuring that the state is immutable, you gain better control over how your UI behaves and reacts to changes. This reduces bugs, simplifies testing, and keeps your widget tree lean and clean.
At the heart of any Flutter screen is its UI state—the data needed to render what users see. In a well-architected app, this state comes from the ViewModel, which exposes it in a way that the view (i.e., the UI) can consume without worrying about where the data comes from or how it's processed.

What Is UI State?
UI state is an immutable snapshot of data needed to fully render a screen. Think of it as the "current frame" of what the user should see—this includes content (like a list of bookings), metadata (like a loading flag), or user-related data (like a profile picture).
The ViewModel is responsible for preparing and exposing this state. It’s designed to keep this state safe and predictable by using read-only access in the UI layer.
Example: Home Screen State
Consider the following HomeViewModel
In a travel booking app. It provides access to two essential pieces of data:
A
User
object representing the current userA list of saved itineraries (
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 = [];
/// Exposing an unmodifiable list for safety
UnmodifiableListView<BookingSummary> get bookings =>
UnmodifiableListView(_bookings);
}
User? get user
allows the UI to read the current user.
bookings
Returns a read-only view of the list so that the UI can’t modify the source directly, reducing the risk of side effects.
This pattern helps protect the integrity of the state and aligns with Flutter’s declarative UI principles.
Enforcing Immutability with Freezing
To further safeguard against bugs, you should ensure your state models are immutable. Flutter developers often use the freezed
to enforce immutability and generate helpful utilities like copyWith
, ==
, and toJson
.
Here’s an example of a deeply immutable User
class using freezed
:
@freezed
class User with _$User {
const factory User({
required String name,
required String picture,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
Why is this important?
You can safely pass instances
User
across the app without worrying they’ll be changed unexpectedly.It helps keep state transitions clear and predictable.
By clearly defining and exposing only the necessary parts of your UI state, and ensuring that the state is immutable, you gain better control over how your UI behaves and reacts to changes. This reduces bugs, simplifies testing, and keeps your widget tree lean and clean.
At the heart of any Flutter screen is its UI state—the data needed to render what users see. In a well-architected app, this state comes from the ViewModel, which exposes it in a way that the view (i.e., the UI) can consume without worrying about where the data comes from or how it's processed.

What Is UI State?
UI state is an immutable snapshot of data needed to fully render a screen. Think of it as the "current frame" of what the user should see—this includes content (like a list of bookings), metadata (like a loading flag), or user-related data (like a profile picture).
The ViewModel is responsible for preparing and exposing this state. It’s designed to keep this state safe and predictable by using read-only access in the UI layer.
Example: Home Screen State
Consider the following HomeViewModel
In a travel booking app. It provides access to two essential pieces of data:
A
User
object representing the current userA list of saved itineraries (
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 = [];
/// Exposing an unmodifiable list for safety
UnmodifiableListView<BookingSummary> get bookings =>
UnmodifiableListView(_bookings);
}
User? get user
allows the UI to read the current user.
bookings
Returns a read-only view of the list so that the UI can’t modify the source directly, reducing the risk of side effects.
This pattern helps protect the integrity of the state and aligns with Flutter’s declarative UI principles.
Enforcing Immutability with Freezing
To further safeguard against bugs, you should ensure your state models are immutable. Flutter developers often use the freezed
to enforce immutability and generate helpful utilities like copyWith
, ==
, and toJson
.
Here’s an example of a deeply immutable User
class using freezed
:
@freezed
class User with _$User {
const factory User({
required String name,
required String picture,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
Why is this important?
You can safely pass instances
User
across the app without worrying they’ll be changed unexpectedly.It helps keep state transitions clear and predictable.
By clearly defining and exposing only the necessary parts of your UI state, and ensuring that the state is immutable, you gain better control over how your UI behaves and reacts to changes. This reduces bugs, simplifies testing, and keeps your widget tree lean and clean.
Updating the UI State
Updating the UI State
Updating the UI State
Updating the UI State
Defining the UI state is only half the job. The real magic of a reactive UI lies in keeping that state up to date and ensuring your widgets re-render automatically when new data becomes available. That’s where updating the UI state—and notifying the UI about those updates—comes in.

Why Do We Need to Notify the View?
Flutter doesn’t automatically know when your ViewModel’s state has changed. To bridge that gap, we need to manually notify the view when data is updated. This triggers a rebuild of any widgets that depend on the state.
In the Compass app (the official Flutter case study), this is achieved by making the ViewModel extend ChangeNotifier
. This is a simple yet powerful way to manage and signal UI updates.
Extending ChangeNotifier
Here’s how the HomeViewModel
is set up:
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;
}
In this structure:
The ViewModel holds private state.
It exposes the state using
get
accessors.By extending
ChangeNotifier
It can signal changes vianotifyListeners()
.
How the UI Gets Notified
Let’s walk through what happens when the view model fetches new data:
Future<Result> _load() async {
try {
final userResult = await _userRepository.getUser();
switch (userResult) {
case Ok<User>():
_user = userResult.value;
case Error<User>():
_log.warning('Failed to load user', userResult.error);
}
// You could update other state here (e.g., bookings)
return userResult;
} finally {
notifyListeners(); // This is key!
}
}
This method is typically triggered when the user navigates to a screen. Here's the step-by-step flow:
User opens the Home screen → ViewModel is created.
_load()
is called → It fetches data from the repositories.The internal state (
_user
) is updated.notifyListeners()
is called.The UI rebuilds using the new state (e.g., displaying the user’s profile or bookings).
Visual Recap: How Data Flows to the UI
The repository returns new data (e.g., a list of bookings).
The ViewModel updates its internal state.
notifyListeners()
is called.Flutter widgets observe the ViewModel rebuild automatically.
This pattern—fetch → update state → notify—makes your UI responsive and consistent with the underlying data, without the need to manually refresh widgets.
✅ Pro Tip: Always ensure
notifyListeners()
is called after your state has changed. Otherwise, you’ll trigger unnecessary rebuilds or miss important updates.
Defining the UI state is only half the job. The real magic of a reactive UI lies in keeping that state up to date and ensuring your widgets re-render automatically when new data becomes available. That’s where updating the UI state—and notifying the UI about those updates—comes in.

Why Do We Need to Notify the View?
Flutter doesn’t automatically know when your ViewModel’s state has changed. To bridge that gap, we need to manually notify the view when data is updated. This triggers a rebuild of any widgets that depend on the state.
In the Compass app (the official Flutter case study), this is achieved by making the ViewModel extend ChangeNotifier
. This is a simple yet powerful way to manage and signal UI updates.
Extending ChangeNotifier
Here’s how the HomeViewModel
is set up:
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;
}
In this structure:
The ViewModel holds private state.
It exposes the state using
get
accessors.By extending
ChangeNotifier
It can signal changes vianotifyListeners()
.
How the UI Gets Notified
Let’s walk through what happens when the view model fetches new data:
Future<Result> _load() async {
try {
final userResult = await _userRepository.getUser();
switch (userResult) {
case Ok<User>():
_user = userResult.value;
case Error<User>():
_log.warning('Failed to load user', userResult.error);
}
// You could update other state here (e.g., bookings)
return userResult;
} finally {
notifyListeners(); // This is key!
}
}
This method is typically triggered when the user navigates to a screen. Here's the step-by-step flow:
User opens the Home screen → ViewModel is created.
_load()
is called → It fetches data from the repositories.The internal state (
_user
) is updated.notifyListeners()
is called.The UI rebuilds using the new state (e.g., displaying the user’s profile or bookings).
Visual Recap: How Data Flows to the UI
The repository returns new data (e.g., a list of bookings).
The ViewModel updates its internal state.
notifyListeners()
is called.Flutter widgets observe the ViewModel rebuild automatically.
This pattern—fetch → update state → notify—makes your UI responsive and consistent with the underlying data, without the need to manually refresh widgets.
✅ Pro Tip: Always ensure
notifyListeners()
is called after your state has changed. Otherwise, you’ll trigger unnecessary rebuilds or miss important updates.
Defining the UI state is only half the job. The real magic of a reactive UI lies in keeping that state up to date and ensuring your widgets re-render automatically when new data becomes available. That’s where updating the UI state—and notifying the UI about those updates—comes in.

Why Do We Need to Notify the View?
Flutter doesn’t automatically know when your ViewModel’s state has changed. To bridge that gap, we need to manually notify the view when data is updated. This triggers a rebuild of any widgets that depend on the state.
In the Compass app (the official Flutter case study), this is achieved by making the ViewModel extend ChangeNotifier
. This is a simple yet powerful way to manage and signal UI updates.
Extending ChangeNotifier
Here’s how the HomeViewModel
is set up:
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;
}
In this structure:
The ViewModel holds private state.
It exposes the state using
get
accessors.By extending
ChangeNotifier
It can signal changes vianotifyListeners()
.
How the UI Gets Notified
Let’s walk through what happens when the view model fetches new data:
Future<Result> _load() async {
try {
final userResult = await _userRepository.getUser();
switch (userResult) {
case Ok<User>():
_user = userResult.value;
case Error<User>():
_log.warning('Failed to load user', userResult.error);
}
// You could update other state here (e.g., bookings)
return userResult;
} finally {
notifyListeners(); // This is key!
}
}
This method is typically triggered when the user navigates to a screen. Here's the step-by-step flow:
User opens the Home screen → ViewModel is created.
_load()
is called → It fetches data from the repositories.The internal state (
_user
) is updated.notifyListeners()
is called.The UI rebuilds using the new state (e.g., displaying the user’s profile or bookings).
Visual Recap: How Data Flows to the UI
The repository returns new data (e.g., a list of bookings).
The ViewModel updates its internal state.
notifyListeners()
is called.Flutter widgets observe the ViewModel rebuild automatically.
This pattern—fetch → update state → notify—makes your UI responsive and consistent with the underlying data, without the need to manually refresh widgets.
✅ Pro Tip: Always ensure
notifyListeners()
is called after your state has changed. Otherwise, you’ll trigger unnecessary rebuilds or miss important updates.
Defining the UI state is only half the job. The real magic of a reactive UI lies in keeping that state up to date and ensuring your widgets re-render automatically when new data becomes available. That’s where updating the UI state—and notifying the UI about those updates—comes in.

Why Do We Need to Notify the View?
Flutter doesn’t automatically know when your ViewModel’s state has changed. To bridge that gap, we need to manually notify the view when data is updated. This triggers a rebuild of any widgets that depend on the state.
In the Compass app (the official Flutter case study), this is achieved by making the ViewModel extend ChangeNotifier
. This is a simple yet powerful way to manage and signal UI updates.
Extending ChangeNotifier
Here’s how the HomeViewModel
is set up:
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;
}
In this structure:
The ViewModel holds private state.
It exposes the state using
get
accessors.By extending
ChangeNotifier
It can signal changes vianotifyListeners()
.
How the UI Gets Notified
Let’s walk through what happens when the view model fetches new data:
Future<Result> _load() async {
try {
final userResult = await _userRepository.getUser();
switch (userResult) {
case Ok<User>():
_user = userResult.value;
case Error<User>():
_log.warning('Failed to load user', userResult.error);
}
// You could update other state here (e.g., bookings)
return userResult;
} finally {
notifyListeners(); // This is key!
}
}
This method is typically triggered when the user navigates to a screen. Here's the step-by-step flow:
User opens the Home screen → ViewModel is created.
_load()
is called → It fetches data from the repositories.The internal state (
_user
) is updated.notifyListeners()
is called.The UI rebuilds using the new state (e.g., displaying the user’s profile or bookings).
Visual Recap: How Data Flows to the UI
The repository returns new data (e.g., a list of bookings).
The ViewModel updates its internal state.
notifyListeners()
is called.Flutter widgets observe the ViewModel rebuild automatically.
This pattern—fetch → update state → notify—makes your UI responsive and consistent with the underlying data, without the need to manually refresh widgets.
✅ Pro Tip: Always ensure
notifyListeners()
is called after your state has changed. Otherwise, you’ll trigger unnecessary rebuilds or miss important updates.
Define a View
Define a View
Define a View
Define a View
In Flutter, the term “view” doesn’t have a fixed definition, but generally, a view is a widget or a group of widgets that represents a complete screen or a self-contained UI component with its own logic and state.
What is a View in Flutter Architecture?
A view is typically a widget that consumes UI state from a ViewModel and displays it to the user. Most of the time, this view corresponds to a screen, like HomeScreen
or LoginScreen
, and contains a Scaffold
node at the root of its widget tree. But not always.
Sometimes a view can be something smaller and reusable, like a LogoutButton
. As long as it connects to its own logic via a ViewModel (LogoutViewModel
In this case, it qualifies as a view within the app's architectural boundaries.
✅ Key idea: A view is a widget or group of widgets that:
Displays UI state from the ViewModel
Rebuilds when that state changes
Calls ViewModel methods on user interaction

Views Aren’t Always Screens
Although views often represent full screens, they can also be reusable UI elements. For example:
HomeScreen
might be a full-screen view with its own route and scaffold.LogoutButton
might be a small, reusable component dropped into multiple screens, but still built as a view because it connects to its own ViewModel.
On larger screens (like tablets or desktops), you may have multiple views on the screen at once, each with its own independent ViewModel.
Clarifying the View-Widget Relationship
A view is not the same as a single widget. It’s often a collection of widgets composed together with a shared purpose and backed by a single ViewModel.
So, while widgets are composable building blocks, the view groups those blocks together and gives them state-awareness via the ViewModel.
Responsibilities of Widgets Within a View
Widgets inside a view generally have three key responsibilities:
Display data from the ViewModel (e.g., show a user’s name or booking list).
Listen for updates to the UI state and rebuild when necessary.
Handle user input by calling appropriate ViewModel methods (e.g., tapping a logout button or submitting a form).
Example: HomeScreen View
Here’s how a simple HomeScreen
is defined with its corresponding HomeViewModel
:
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
// Build the UI using viewModel.user and viewModel.bookings
);
}
}
The
viewModel
is passed into the view, making the data flow explicit and testable.The view’s only other input is typically a
Key
, which all widgets accept for identification.All display logic (rendering bookings, showing loading spinners, etc.) is driven by the ViewModel.
In Flutter, the term “view” doesn’t have a fixed definition, but generally, a view is a widget or a group of widgets that represents a complete screen or a self-contained UI component with its own logic and state.
What is a View in Flutter Architecture?
A view is typically a widget that consumes UI state from a ViewModel and displays it to the user. Most of the time, this view corresponds to a screen, like HomeScreen
or LoginScreen
, and contains a Scaffold
node at the root of its widget tree. But not always.
Sometimes a view can be something smaller and reusable, like a LogoutButton
. As long as it connects to its own logic via a ViewModel (LogoutViewModel
In this case, it qualifies as a view within the app's architectural boundaries.
✅ Key idea: A view is a widget or group of widgets that:
Displays UI state from the ViewModel
Rebuilds when that state changes
Calls ViewModel methods on user interaction

Views Aren’t Always Screens
Although views often represent full screens, they can also be reusable UI elements. For example:
HomeScreen
might be a full-screen view with its own route and scaffold.LogoutButton
might be a small, reusable component dropped into multiple screens, but still built as a view because it connects to its own ViewModel.
On larger screens (like tablets or desktops), you may have multiple views on the screen at once, each with its own independent ViewModel.
Clarifying the View-Widget Relationship
A view is not the same as a single widget. It’s often a collection of widgets composed together with a shared purpose and backed by a single ViewModel.
So, while widgets are composable building blocks, the view groups those blocks together and gives them state-awareness via the ViewModel.
Responsibilities of Widgets Within a View
Widgets inside a view generally have three key responsibilities:
Display data from the ViewModel (e.g., show a user’s name or booking list).
Listen for updates to the UI state and rebuild when necessary.
Handle user input by calling appropriate ViewModel methods (e.g., tapping a logout button or submitting a form).
Example: HomeScreen View
Here’s how a simple HomeScreen
is defined with its corresponding HomeViewModel
:
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
// Build the UI using viewModel.user and viewModel.bookings
);
}
}
The
viewModel
is passed into the view, making the data flow explicit and testable.The view’s only other input is typically a
Key
, which all widgets accept for identification.All display logic (rendering bookings, showing loading spinners, etc.) is driven by the ViewModel.
In Flutter, the term “view” doesn’t have a fixed definition, but generally, a view is a widget or a group of widgets that represents a complete screen or a self-contained UI component with its own logic and state.
What is a View in Flutter Architecture?
A view is typically a widget that consumes UI state from a ViewModel and displays it to the user. Most of the time, this view corresponds to a screen, like HomeScreen
or LoginScreen
, and contains a Scaffold
node at the root of its widget tree. But not always.
Sometimes a view can be something smaller and reusable, like a LogoutButton
. As long as it connects to its own logic via a ViewModel (LogoutViewModel
In this case, it qualifies as a view within the app's architectural boundaries.
✅ Key idea: A view is a widget or group of widgets that:
Displays UI state from the ViewModel
Rebuilds when that state changes
Calls ViewModel methods on user interaction

Views Aren’t Always Screens
Although views often represent full screens, they can also be reusable UI elements. For example:
HomeScreen
might be a full-screen view with its own route and scaffold.LogoutButton
might be a small, reusable component dropped into multiple screens, but still built as a view because it connects to its own ViewModel.
On larger screens (like tablets or desktops), you may have multiple views on the screen at once, each with its own independent ViewModel.
Clarifying the View-Widget Relationship
A view is not the same as a single widget. It’s often a collection of widgets composed together with a shared purpose and backed by a single ViewModel.
So, while widgets are composable building blocks, the view groups those blocks together and gives them state-awareness via the ViewModel.
Responsibilities of Widgets Within a View
Widgets inside a view generally have three key responsibilities:
Display data from the ViewModel (e.g., show a user’s name or booking list).
Listen for updates to the UI state and rebuild when necessary.
Handle user input by calling appropriate ViewModel methods (e.g., tapping a logout button or submitting a form).
Example: HomeScreen View
Here’s how a simple HomeScreen
is defined with its corresponding HomeViewModel
:
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
// Build the UI using viewModel.user and viewModel.bookings
);
}
}
The
viewModel
is passed into the view, making the data flow explicit and testable.The view’s only other input is typically a
Key
, which all widgets accept for identification.All display logic (rendering bookings, showing loading spinners, etc.) is driven by the ViewModel.
In Flutter, the term “view” doesn’t have a fixed definition, but generally, a view is a widget or a group of widgets that represents a complete screen or a self-contained UI component with its own logic and state.
What is a View in Flutter Architecture?
A view is typically a widget that consumes UI state from a ViewModel and displays it to the user. Most of the time, this view corresponds to a screen, like HomeScreen
or LoginScreen
, and contains a Scaffold
node at the root of its widget tree. But not always.
Sometimes a view can be something smaller and reusable, like a LogoutButton
. As long as it connects to its own logic via a ViewModel (LogoutViewModel
In this case, it qualifies as a view within the app's architectural boundaries.
✅ Key idea: A view is a widget or group of widgets that:
Displays UI state from the ViewModel
Rebuilds when that state changes
Calls ViewModel methods on user interaction

Views Aren’t Always Screens
Although views often represent full screens, they can also be reusable UI elements. For example:
HomeScreen
might be a full-screen view with its own route and scaffold.LogoutButton
might be a small, reusable component dropped into multiple screens, but still built as a view because it connects to its own ViewModel.
On larger screens (like tablets or desktops), you may have multiple views on the screen at once, each with its own independent ViewModel.
Clarifying the View-Widget Relationship
A view is not the same as a single widget. It’s often a collection of widgets composed together with a shared purpose and backed by a single ViewModel.
So, while widgets are composable building blocks, the view groups those blocks together and gives them state-awareness via the ViewModel.
Responsibilities of Widgets Within a View
Widgets inside a view generally have three key responsibilities:
Display data from the ViewModel (e.g., show a user’s name or booking list).
Listen for updates to the UI state and rebuild when necessary.
Handle user input by calling appropriate ViewModel methods (e.g., tapping a logout button or submitting a form).
Example: HomeScreen View
Here’s how a simple HomeScreen
is defined with its corresponding HomeViewModel
:
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
// Build the UI using viewModel.user and viewModel.bookings
);
}
}
The
viewModel
is passed into the view, making the data flow explicit and testable.The view’s only other input is typically a
Key
, which all widgets accept for identification.All display logic (rendering bookings, showing loading spinners, etc.) is driven by the ViewModel.
Display UI data in a view
Display UI data in a view
Display UI data in a view
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,
),
),
),
],
);
},
),
),
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,
),
),
),
],
);
},
),
),
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,
),
),
),
],
);
},
),
),
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
Update the UI
Update the UI
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,
),
),
),
],
);
}
)
)
);
}
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,
),
),
),
],
);
}
)
)
);
}
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,
),
),
),
],
);
}
)
)
);
}
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
Handling user events
Handling user events
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.
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.
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.
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
Command objects
Command objects
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.
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.
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.
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
Ensuring views can render before data exists
Ensuring views can render before data exists
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 is 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.
Conclusion
Build UI That’s Clean, Reactive, and Testable
A well-structured UI layer is essential for building maintainable and scalable Flutter applications. By leveraging view models to manage logic, defining a structured UI state, and embracing Flutter’s declarative UI approach, you ensure your app remains clean, testable, and responsive to user interactions.
Following the 9-step structure outlined in this guide helps eliminate bloated widgets, simplifies testing, and improves developer collaboration. Ultimately, a thoughtful UI architecture empowers your team to ship robust features faster, with fewer bugs and a better user experience.
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 is 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.
Conclusion
Build UI That’s Clean, Reactive, and Testable
A well-structured UI layer is essential for building maintainable and scalable Flutter applications. By leveraging view models to manage logic, defining a structured UI state, and embracing Flutter’s declarative UI approach, you ensure your app remains clean, testable, and responsive to user interactions.
Following the 9-step structure outlined in this guide helps eliminate bloated widgets, simplifies testing, and improves developer collaboration. Ultimately, a thoughtful UI architecture empowers your team to ship robust features faster, with fewer bugs and a better user experience.
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 is 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.
Conclusion
Build UI That’s Clean, Reactive, and Testable
A well-structured UI layer is essential for building maintainable and scalable Flutter applications. By leveraging view models to manage logic, defining a structured UI state, and embracing Flutter’s declarative UI approach, you ensure your app remains clean, testable, and responsive to user interactions.
Following the 9-step structure outlined in this guide helps eliminate bloated widgets, simplifies testing, and improves developer collaboration. Ultimately, a thoughtful UI architecture empowers your team to ship robust features faster, with fewer bugs and a better user experience.
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 is 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.
Conclusion
Build UI That’s Clean, Reactive, and Testable
A well-structured UI layer is essential for building maintainable and scalable Flutter applications. By leveraging view models to manage logic, defining a structured UI state, and embracing Flutter’s declarative UI approach, you ensure your app remains clean, testable, and responsive to user interactions.
Following the 9-step structure outlined in this guide helps eliminate bloated widgets, simplifies testing, and improves developer collaboration. Ultimately, a thoughtful UI architecture empowers your team to ship robust features faster, with fewer bugs and a better user experience.
Table of content
© 2021-25 Blupx Private Limited.
All rights reserved.
© 2021-25 Blupx Private Limited.
All rights reserved.
© 2021-25 Blupx Private Limited.
All rights reserved.