App Architecture & Design Patterns

The Presentation Layer & MVVM

16 min Lesson 6 of 12

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
Note: In Flutter, the ViewModel does not hold a reference to the View. Communication flows one way: the ViewModel exposes state; the View observes it. This eliminates tight coupling and makes testing trivial — you can unit-test the ViewModel without a widget tree.

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
    }
  }
}
Tip: Keep every field that the widget tree might display inside the ViewModel. This includes loading flags, error messages, and the actual data. Never let the View make direct async calls or contain 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)),
);
Warning: Never inject a 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)
Key Takeaway: The ViewModel is the single place where UI state lives and business operations are triggered. Views are dumb — they observe and render. UseCases are invoked — they execute domain logic. This separation makes each layer independently testable and swappable without touching the others.