Discussion: MVVM pattern for Flutter apps

Learning architecture patterns for software development is crucial for a "better" development experience.

However, applying a certain architecture pattern as-is doesn't guarantee achieving the experience improvement, or sometimes it introduces more downsides than benefits.

MVVM is one of the most popular architecture patterns for mobile app development. In this article, I'll discuss how MVVM can be applied to Flutter apps, and how it "fits" to Flutter app development, or maybe not.

One important fact about architecture and patterns

Let’s start with the discussion on how we leverage software architecture patterns.

When thinking about software architecture, it's important to keep in mind that we don't have any absolute “best practices” or "silver bullets".

Because all the projects out there are different in many ways, such as business conditions, team members, tech stacks, and development process, the architecture itself should be different. In addition, those conditions change with time, meaning the architecture also should be changed over time.

Architecture patterns are not the "solutions" but just "starting points" to consider the suitable architecture for each project.

Martin Fowler said in his book "Patterns of Enterprise Application Architecture":

I like to say that patterns are “half baked,” meaning that you always have to finish them off in the oven of your own project.

Patterns are not the goal but we need to optimize them for our project considering the conditions listed above.

Thus, I won't discuss the "correct" way of applying the MVVM pattern to Flutter apps, but rather discuss how it can be applied to Flutter apps and how it fits to Flutter apps by building a simple and typical TODO app.

What is MVVM?

MVVM is one of the most famous architecture patterns for mobile apps.

Originally, MVVM was introduced for Windows Presentation Foundation (WPF) apps.

https://learn.microsoft.com/en-us/dotnet/architecture/maui/mvvm

Though we know the pattern with the name "MVVM", the pattern was originally the optimized version of the Presentation Model pattern for WPF / Silverlight apps.

Fowler introduced Presentation Model as a means of creating a UI platform-independent abstraction of a View, whereas Gossman introduced MVVM as a standardized way to leverage core features of WPF to simplify the creation of user interfaces.

https://learn.microsoft.com/en-us/archive/msdn-magazine/2009/february/patterns-wpf-apps-with-the-model-view-viewmodel-design-pattern

Thus, when thinking about MVVM outside of WPF / Silverlight apps, it's better to refer Presentation Model pattern to catch its essence.

https://martinfowler.com/eaaDev/PresentationModel.html

MVVM, or Presentation Model, consists of the following 3 components:

  • Model: Represents the data and business logic of the application.

  • View: Represents the UI of the application.

  • ViewModel: Represents the state and behavior of the UI.

Note that I call the "Presentation Model" component "ViewModel" in this article for convenience.

Then, let's take a closer look at each component.

Model

Model is the component that represents the data and business logic of the application.

Because each business should have its own data to be stored and logics to be processed even if they don't hire any software, Model represents the data and logic as an automated program.

For example, considering managing a TODO list, we write down our TODO on our paper if we don’t have software. We would list our TODO one by one, we woul sometimes consider deadlines and hours left looking at our watch, or we would sometimes share the list with others.

Model does those businesses.

As Model is an abstraction of the business, it should be UI-independent, meaning platform-independent as well.

When developing Flutter apps, Model will ideally be implemented in pure Dart code and can be used by both Flutter apps and Dart CLI apps possibly.

View

View is the component that represents the UI of the application.

Users may want to see the data and manipulate them without writing any program. Here comes View which lets them interact with the software.

View is implemented with UI frameworks, Flutter in this case.

One concern about View is that UI is relatively complicated and difficult to test because it strongly depends on the restrictions of UI frameworks or platforms.

Therefore, we may want to separate our program into UI and non-UI parts so that we can implement and test them separately.

ViewModel (Presentation Model)

ViewModel is the component that represents the state and behavior of the UI.

Although View displays the underlying data from Model, they tend not to display the data as-is. They need some conversions or transformations to be displayed on the screen.

In addition, View may need the data not only from Model but also from what happens on the screen, such as user interactions or animations.

View wants to focus on constructing UI components, which means those conversions or state managements are not desired to be done in View.

Here comes ViewModel. ViewModel will do all the state management for View. All View needs to do is display the exposed data from ViewModel.

Another role of ViewModel is to handle the user interactions.

When a certain event happens, the system needs to consider what manipulations need to be done. In other words, it needs to know an appropriate process to handle the event.

In modern apps, this process can potentially be complex requiring multiple models and logic, which View may not want to handle inside itself.

View can just call the corresponding methods which ViewModel provides when the event happens.

In short, ViewModel helps View focus on constructing and displaying UI components by providing UI state and handlers for user interactions.

Data consistency

Separating the software into multiple components will introduce another problem to be solved: data consistency.

According to Fowler, we typically have three copies of the data fetched from the database, such as:

  • record state

  • session state

  • screen state

https://martinfowler.com/eaaDev/uiArchs.html

We usually store our data in the database. Fowler calls it "record state".

Then, a certain system will fetch the data from the database and the data will be copied into its memory, called "session state".

Finally, the data will be delivered to the UI with whatever method to displayed on the screen. This is called "screen state".

Because the data changes over time, all the states need to be synchronized when the data is updated for whatever reason. Otherwise, users will see old and currently incorrect data on the screen even though the actual data is updated.

We need to solve this problem with an appropriate architecture other than just "carefully programming".

That's one of the motivations to apply the MVVM pattern or other MVx patterns, such as MVC or MVP.


So far, we have discussed the two issues to be solved when building GUI applications:

  • separating concerns into UI and non-UI parts

  • keeping data consistency

Let's continue our discussion on how we can solve them with the MVVM pattern in the Flutter app.

Implementing MVVM pattern for Flutter apps

In the rest of the article, we will discuss how the MVVM pattern can be applied to Flutter apps by taking a look at my sample TODO app below.

https://github.com/chooyan-eng/todo_demoapp

This app has typical features for TODO apps, such as:

  • listing TODOs

  • adding, editing, deleting TODOs

  • listing sharing members

  • assigning a TODO to a member

Each feature is implemented based on MVVM architecture with only Flutter/Dart's built-in components without any state management packages.

Keep in mind that this is NOT a “reference” app, but just a "sample" app for discussion. You don't need to follow the concrete implementation when you apply the idea to your app, and you will use whatever useful packages, such as get_it, bloc, riverpod, and more. I just don't use any additional packages in this app for fair discussion.

Model

A typical Model class in this app would be TodoModel.

This maintains Todo data and business logic when adding, editing, and deleting TODOs.

As model classes are UI-independent, this class only depends on Dart libraries, such as dart:async, or other Flutter-independent packages, such as intl or collection.

import 'dart:async';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';

class TodoModel {
}

TodoModel itself is also a pure Dart class without any Flutter-originated components.

TodoModel provides a method to fetch/add/edit/delete TODOs with concrete implementations.

Note that this app hires a simple in-memory storage for TODOs, and it doesn't persist the data to any database to avoid unnecessary complexities.

class TodoModel {

  final TodoStorage _storage;

  void addTodo({
    required String title,
    required double estimatedHours,
    required DateTime deadline,
    required Member assignee,
  }) {
    final todo = // create a new todo with given arguments;
    _storage.save(todo);
 }
}

Because TodoModel is potentially depended on multiple ViewModels, it is required to "notify" the data changes to all the dependent ViewModels other than “return” from each method. Therefore, TodoModel implements a mechanism for the feature with a StreamController with broadcast mode.

class TodoModel {

  // provide broadcast stream to notify data updates to ViewModel
  final _todoController = StreamController<List<Todo>>.broadcast();

  void addTodo() {
    final todo = // create a new todo with given arguments;
    _storage.save(todo);

    // refetch all todos from the storage
    final allTodo = _storage.fetchAll();
    // notify latest data to all the dependent ViewModels
    _todoController.add(allTodo);
  }
}

This mechanism is the key to achieving consistency of data. The same approach is originally introduced by other patterns such as MVC or MVP to share the latest data with relevant components.

Another point on the Model layer is the implementation of business logic.

For example, when this app requires some rules when adding a TODO, such as:

  • the deadline should be after the current date

  • considering other TODOs, sufficient hours have to be left before the deadline

Those rules are implemented at Model because they are UI-independent and, let's say, if we need a CLI version of this app, those rules are still necessary.

class TodoModel {

  void addTodo({
    required String title,
    required double estimatedHours,
    required DateTime deadline,
    required Member assignee,
  }) {
    final hoursUntilDeadline =
        newTodoDeadline.difference(DateTime.now()).inHours;
    if (hoursUntilDeadline < newTodoHours) {
      // throw an exception if the deadline is before the current date
      throw DeadlineRestrictionException(
        'Not enough time to complete this task. Task needs $newTodoHours hours but only $hoursUntilDeadline hours available until deadline.',
      );
    }

    final todosBeforeDeadline = _fetchTodosBefore(newTodoDeadline);

    // Calculate total hours needed for existing todos
    final totalExistingHours = todosBeforeDeadline.fold<double>(
      0,
      (sum, todo) => sum + todo.estimatedHours,
    );

    // Check if there's enough time for both existing todos and new todo
    final totalRequiredHours = totalExistingHours + newTodoHours;
    if (totalRequiredHours > hoursUntilDeadline) {
      throw DeadlineRestrictionException(
        'Not enough time to complete this task. You already have $totalExistingHours hours of tasks before this deadline.',
      );
    }

    // if everything is fine, create a new todo
    _storage.save(todo);
    // and notify the latest data to all the dependent ViewModels
    final allTodo = _storage.fetchAll();
    _todoController.add(allTodo);
  }
}

A typical benefit of implementing business logic inside this independent layer is testability.

Because Model is UI-independent, we can test the business logic with unit tests, not widget tests or integration tests.

Because widget tests and integration tests cost more than unit tests in general, we can reduce the cost of writing test code, and this enables us to introduce more and better test suites.

"Better" test suites will tell us the precise behavior of the system. Even non-programmers in the project will catch how the system works by reading the test titles and descriptions.

void main() {
  group('test for adding todo feature', () {
    group('test deadline restrictions', () {
      test('should avoid adding todo when deadline is too soon for estimated hours', () {
        // ...some test code
      });

      test('should throw when not enough time considering existing todos', () {
        // ...some test code
      });
    });
  });
}

Because not all the projects have enough time to spend on writing "specification documentation" and synchronizing it with the actual software, test suites will be a nice alternative to let non-programmers understand the system.

View

TodoListPage would be the typical demonstration class for View.

TodoListPage has its corresponding ViewModel, TodoListViewModel. TodoListPage creates and holds an instance of TodoListViewModel to interact with it.

Because we need to instantiate and hold an instance of ViewModel in the widget, TodoListPage is a subclass of StatefulWidget and doing that in initState method.

class TodoListView extends StatefulWidget {
  const TodoListView({super.key});

  @override
  State<TodoListView> createState() => _TodoListViewState();
}

class _TodoListViewState extends State<TodoListView> {
  late final TodoListViewModel _viewModel;

  @override
  void initState() {
    super.initState();

    // instantiate and hold an instance of ViewModel
    _viewModel = TodoListViewModel(
      // retrieve a model instance from the widget tree 
      ModelProvider.todoModelOf(context),
    );
  }

  @override
  Widget build(BuildContext context) {
    // ...build UI with ViewModel
  }
}

View never implements any logic, regardless of its complexity. They just "ask" their corresponding ViewModels for all the data they use to construct their UI.

@override
Widget build(BuildContext context) {
  return ValueListenableBuilder<TodoViewState>(
    valueListenable: _viewModel,
    builder: (context, state, _) {
      // ViewModel exposes .filteredTodos state and View just use it
      final todos = state.filteredTodos;

      return // ...build UI with todos
        // ...some more code here...

        IconButton(
          icon: Icon(
            // ViewModel exposes .showCompleted state and View just use it
            state.showCompleted
                ? Icons.check_circle
                : Icons.check_circle_outline,
          ),
          // ViewModel exposes .showCompleted state and View just use it
          tooltip: state.showCompleted
              ? 'Hide completed tasks'
              : 'Show completed tasks',
      );
    },
  );
}

User interactions are also handled by calling ViewModel's methods.

IconButton(
  // View just calls ViewModel's method when the interaction happens
  onPressed: _viewModel.toggleShowCompleted,
);

This would keep the View focusing on constructing UI components. This is important, especially for Flutter apps, where the shape of the widget tree affects the layout and behavior of the UI.

In addition, UI would be updated frequently depending on designers' feedback or introducing new features. In that case, it'll be easy to maintain the code if View has only UI-related codes.

ViewModel

TodoListView has its corresponding ViewModel, TodoListViewModel.

TodoListViewModel is required to:

  • maintain UI state so that TodoListView can display them as-is

  • provide methods to handle user interactions using relevant Model's methods

  • fetch data from Model(s) and notify the changes to TodoListView

In this sample app, "UI state" is represented with a single immutable data class, named TodoListViewState.

class TodoListViewState {
  TodoListViewState({
    required this.todos,
    required this.showCompleted,
  });

  final List<Todo> todos;
  final bool showCompleted;

  List<Todo> get filteredTodos =>
      showCompleted ? todos : todos.where((todo) => !todo.isCompleted).toList();
}

The fields and getters in TodoViewState are all required for TodoListView to construct UI. In other words, TodoListView's UI would always be changed depending on those fields and getters.

To synchronize the data between TodoListViewModel and TodoListView, TodoListViewModel extends ValueNotifier, which notifies the changes when value with type of TodoViewState is changed.

class TodoListViewModel extends ValueNotifier<TodoListViewState> {
}

TodoListViewModel always listens to the data changes from TodoModel and updates the value of ValueNotifier when the data is changed.

class TodoListViewModel extends ValueNotifier<TodoListViewState> {
  TodoListViewModel(this._model)
      : super(TodoListViewState(todos: [], showCompleted: true)) {
    // start listening to the data changes from Model
    _model.todoStream.listen((todos) {
      // update TodoListViewState when the data is changed
      value = value.copyWith(todos: todos);
    });
  }
}

TodoListViewModel also implements the methods to handle user interactions.

class TodoListViewModel extends ValueNotifier<TodoListViewState> {
  void deleteTodo(String id) {
    _model.deleteTodo(id);
  }

  void toggleShowCompleted() {
    value = value.copyWith(showCompleted: !value.showCompleted);
  }
}

Some methods will call the corresponding methods of TodoModel but others are just updating the value of ValueNotifier to notify the changes to TodoListView, because some operations need to update the data on the database while others just update on-memory data.

ViewModel introduces the benefit that we can overview all the required data and possible operations in the corresponding View, TodoListView in this case, by looking at TodoListViewModel's fields and methods.

Discussion on MVVM pattern for Flutter apps

As we discussed what the MVVM pattern is, and how it looks like in Flutter apps, let's wrap up the article by discussing "Does MVVM fit to Flutter apps?".

Though MVVM (or Presentation Model) pattern helps us solve typical issues happening in GUI applications, Flutter and its ecosystem provide other suitable options.

Although Model preserves the data in MVVM pattern, For example, Flutter has another approach to achieve data consistency of "app state" managed using InheritedWidget or other state management packages, such as riverpod, bloc, or many others.

If we want to share a single data, which Model preserves in MVVM pattern, to multiple Views, we can just use InheritedWidget or its alternatives. Fortunately, some packages provide Flutter-independent classes like riverpod, bloc, etc, which enables us to separate those classes from UI frameworks.

Each package is based on its own idea and would introduce recommended usage, so it tends to be better to follow the approach.

In addition, the typical roles of ViewModel are:

  • providing UI state so that View can display them as-is

  • updating UI when the UI state is changed

  • handling user interactions

  • fetching data from Model

but we have StatefulWidget, or other packages like flutter_hooks, for managing UI state. A declarative UI building approach would solve the concern about synchronizing data and UI state automatically.

I never say that the MVVM pattern is not useful for Flutter apps, but I would say we have more suitable options when developing Flutter apps.

Conclusion

So far, we have discussed how MVVM pattern looks like in Flutter apps referring the original idea of Presentation Model pattern with the sample TODO app.

In conclusion, we don't have a lot of projects where applying MVVM as-is would fit well because we have other options optimized for Flutter apps.

It’s important to understand the essence of the pattern and what issues they try to solve. That understanding will let us know what issues typically have to be solved regardless of the architectures, such as data consistency, separating concerns into UI and non-UI parts, etc.

See my GitHub repository for the full implementation of the sample app:

https://github.com/chooyan-eng/todo_demoapp

Bonus Tips: Coding with Cursor

This app was built with Cursor, a modern code editor with AI assistance.

https://www.cursor.com/

You may be surprised to hear that 80 ~ 90% of the code of the TODO app was generated by Cursor, and what I did was just write some documents about the architecture and some specs about the features.

Cursor will read the document and properly generate the entire code with a simple prompt:

@docs // <- this tells Cursor to read the `docs` folder
Generate the editing TODO feature.

This ends up generating the entire code related to the editing TODO feature, including View, ViewModel, and some parts of Model with nicely looking UI.

This boosts our development so much, especially when building this kind of sample app, and we can focus on the investigation of our interests.

I'm still not sure this is valuable for "real" projects, but it's already a good friend at least for making sample apps for learning or investigation purposes.