The Presentation Layer & MVVM
The Presentation Layer & MVVM
In a well-architected Flutter application, the presentation layer is the outermost ring of your onion architecture. It is responsible for rendering UI and reacting to user interactions — nothing more. All business decisions, validation, and data transformations belong elsewhere. The MVVM (Model-View-ViewModel) pattern gives this layer a clean internal structure by introducing a ViewModel as the bridge between the UI and the domain.
Why MVVM in Flutter?
Flutter widgets can very easily become bloated with business logic: fetching data, transforming responses, handling errors, and managing loading indicators all crammed into a single build() method. MVVM prevents this by separating concerns:
- Model — your domain entities and data sources (UseCases, Repositories)
- View — the widget tree; purely declarative, contains zero business logic
- ViewModel — holds UI state, calls UseCases, and notifies the View to rebuild
Building a ViewModel with ChangeNotifier
The simplest way to implement a ViewModel in Flutter is to extend ChangeNotifier from package:flutter/foundation.dart. Whenever state changes you call notifyListeners(), which triggers rebuilds in every Consumer or ListenableBuilder that watches this object.
UserProfileViewModel (ChangeNotifier)
import 'package:flutter/foundation.dart';
import '../domain/use_cases/get_user_profile_use_case.dart';
import '../domain/entities/user_profile.dart';
/// ViewModel for the User Profile screen.
/// Calls the GetUserProfileUseCase and exposes UI state.
class UserProfileViewModel extends ChangeNotifier {
final GetUserProfileUseCase _getUserProfile;
// --- UI State ---
bool _isLoading = false;
UserProfile? _profile;
String? _errorMessage;
bool get isLoading => _isLoading;
UserProfile? get profile => _profile;
String? get errorMessage => _errorMessage;
UserProfileViewModel({required GetUserProfileUseCase getUserProfile})
: _getUserProfile = getUserProfile;
Future<void> loadProfile(String userId) async {
_isLoading = true;
_errorMessage = null;
notifyListeners(); // tell the View: show a spinner
try {
_profile = await _getUserProfile(userId);
} catch (e) {
_errorMessage = 'Failed to load profile: $e';
} finally {
_isLoading = false;
notifyListeners(); // tell the View: hide the spinner, show data or error
}
}
}
if/else business logic.The View: A Purely Reactive Widget
The View subscribes to the ViewModel using ListenableBuilder (Flutter 3.7+) or a Provider Consumer. It reads state and rebuilds whenever notifyListeners() fires — the widget itself contains no logic beyond mapping state to widgets.
UserProfileView (pure View)
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'user_profile_view_model.dart';
class UserProfileView extends StatelessWidget {
final String userId;
const UserProfileView({super.key, required this.userId});
@override
Widget build(BuildContext context) {
final vm = context.watch<UserProfileViewModel>();
// Trigger data load once after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (vm.profile == null && !vm.isLoading) {
vm.loadProfile(userId);
}
});
if (vm.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (vm.errorMessage != null) {
return Center(child: Text(vm.errorMessage!));
}
final profile = vm.profile;
if (profile == null) return const SizedBox.shrink();
return Column(
children: [
CircleAvatar(backgroundImage: NetworkImage(profile.avatarUrl)),
Text(profile.displayName, style: const TextStyle(fontSize: 20)),
Text(profile.email),
],
);
}
}
Using StateNotifier for Immutable State
When you use Riverpod, the idiomatic choice is StateNotifier<S> paired with a sealed UI-state class. The ViewModel holds all state transitions in one place and emits a completely new immutable state object on every change — no mutable fields, no individual notifyListeners() calls.
UserProfileNotifier (Riverpod StateNotifier)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../domain/use_cases/get_user_profile_use_case.dart';
import '../domain/entities/user_profile.dart';
// Sealed UI state
sealed class UserProfileState {}
class UserProfileInitial extends UserProfileState {}
class UserProfileLoading extends UserProfileState {}
class UserProfileLoaded extends UserProfileState {
final UserProfile profile;
UserProfileLoaded(this.profile);
}
class UserProfileError extends UserProfileState {
final String message;
UserProfileError(this.message);
}
// ViewModel / StateNotifier
class UserProfileNotifier extends StateNotifier<UserProfileState> {
final GetUserProfileUseCase _getUserProfile;
UserProfileNotifier(this._getUserProfile) : super(UserProfileInitial());
Future<void> loadProfile(String userId) async {
state = UserProfileLoading();
try {
final profile = await _getUserProfile(userId);
state = UserProfileLoaded(profile);
} catch (e) {
state = UserProfileError('Could not load profile.');
}
}
}
// Riverpod provider
final userProfileProvider =
StateNotifierProvider.autoDispose<UserProfileNotifier, UserProfileState>(
(ref) => UserProfileNotifier(ref.read(getUserProfileUseCaseProvider)),
);
BuildContext into a ViewModel. If you need navigation after an async operation, expose a side-effect stream or a one-shot ValueNotifier that the View observes and acts on. Keeping context out of the ViewModel is what makes it testable.The Dependency Flow
In MVVM the dependency arrows always point inward — from outer layers toward the domain:
- View depends on ViewModel (reads state, calls methods)
- ViewModel depends on UseCase interfaces (never on concrete data-layer classes)
- UseCases depend on Repository interfaces (from the domain layer)