Flutter: Clean and Simple State Management with pure MVVM

Martin Nowosad
ITNEXT
Published in
10 min readOct 30, 2023

--

Introduction

While browsing LinkedIn during my lunch break, I found this poll

original: https://www.linkedin.com/feed/update/urn:li:activity:7121977877197119488?utm_source=share&utm_medium=member_desktop

I left a comment that I’ve been successfully avoiding any state management libraries for Flutter apps by leveraging MVVM and I was then asked how I’d do it. Well, here we go.

This article is about proper and clean state management in Flutter without the need of any 3rd party libraries. We’ll use pure software engineering design patterns that have been out there for decades: Observer Pattern, Factories, Dependency Injection (for testability). These patterns are at the heart of most modern state management libraries such as BloC, MobX, Get, …

By applying those techniques properly we’ll end up with a simple pattern that we can re-use all over our application and — to me most importantly — we don’t have to bloat our App with magic codes (e.g. wrapping our Widgets with weird generated Provider code and such).

Your project will not only benefit from less magic — but also from less (critical!) dependencies. BloC, for example, is currently at version 8.12. With every major Version bump there were API changes introduced which means your code just turned into legacy code and you collected technical debt. We can get rid of this completely by taking state management into our hands — and with this article you’ll hopefully see, that it is not so complicated to do so.

Who is this article for?

For anyone who would like to understand how State management is done in general and how you can achieve it without using any library — especially in Flutter.

This article will not explain the different design patterns that were mentioned in the introduction sentence, you can look them up on wikipedia.

Before we start, let’s clarify first why state management is important and what MVVM is.

Why State Management?

State Management is crucial — especially for mobile Applications. UI Elements of a mobile framework (e.g. Flutter or Android Native) have a lifecycle which is bound to physical restrictions of the device and external events. Means, those UI Elements (Widgets in Flutter) can unexpectedly be destroyed and re-created based when for example the phone is rotated or the user receives an incoming call and gets “kicked” out of the app by his device. Whenever this happens, it is important that your state is restored properly so when the user jumps back into the app, he doesn’t have to fill the forms in again (Imagine a messaging app where the user just typed multiple sentences and then suddenly his screen rotates and the input is gone). This is why different state management patterns were developed and few of them turned out to become industry standards —The most popular of these are MVC and MVVM.

What is MVVM?

MVVM — short for Model-View-ViewModel — is a design pattern that splits the state from the UI (View) by introducing ViewModels and Models. The underlying pattern is the observer/observable pattern. The ViewModel serves as the Observable and the UI as Observer. On a high level it works like this: Whenever the User interacts with the app, the event is registered within the UI (e.g. the Widget), the UI then calls a method on the ViewModel, which then propagates the call to the Model (e.g. our repository or domain layer) and then emit an update to all registered Observers. Below you can find a high level sequence diagram of how MVVM works (the detail that UI implements an observer interface and ViewModel an observable is hidden)

high level overview of how MVVM works

Implementation in Flutter

We’ll implement now MVVM in Dart and apply it to Flutter. For this, we’ll design a simple To-Do App. We will have a simple StatefulWidget class that serves as UI and as per Flutter way of things, holds a State class. That State class will implement our Observer and reference and subscribe to a ViewModel, which implements the Observable interface. The ViewModel will contain a Repository, fetch data and then trigger an update of the data. By the nature of the observer pattern, all subscribed observers will receive that update (in our case, the UI).

Step 1: Create a fresh new project

I assume you know how to create a new Flutter Project in Android Studio, name it whatever you want.

Step 2: Create the Observer

Create within your lib package a new package called mvvm . Within mvvm create a file called observer.dart Copy paste following code into the observer file

abstract class EventObserver {
void notify(ViewEvent event);

}

abstract class ViewEvent {
String qualifier;

ViewEvent(this.qualifier);

@override
String toString() {
return 'ViewEvent{qualifier: $qualifier}';
}
}

Let’s break it down: Every observer to implement the notify(ViewEvent event) method. It is where the magic happens: This method is invoked by the ViewModel and should contain the state update logic of the UI.

Step 3: Create the ViewModel

Create now in the same package a viewmodel.dart file and insert following code

import 'observer.dart';

abstract class EventViewModel {
final List<EventObserver> _observerList = List.empty(growable: true);

void subscribe(EventObserver o) {
if (_observerList.contains(o)) return;

_observerList.add(o);
}

bool unsubscribe(EventObserver o) {
if (_observerList.contains(o)) {
_observerList.remove(o);
return true;
} else {
return false;
}
}

void notify(ViewEvent event) {
for (var element in _observerList) {
element.notify(event);
}
}
}

This will serve as base class for our ViewModels. By extending EventViewModel, any class that implements EventObserver can register and listen for updates. Further on, the class that extends EventViewModel will own an internal collection of observers, which is populated whenever a new observer calls subscribe on the view model. It also contains a unsubscribe method to properly remove the observer.

When notify is called, then the ViewModel iterates through its collection of observers and propagates the event. This method is what will trigger the state update on the UI.

That’s it. We have now all the key ingredients in place to do proper state management.

If you created a fresh new project then your package structure should look like mine

Step 4: Designing the architecture of our App

Now that we have our fundamentals in place, let’s build our TODO app. We’ll build a simple fetching mechanism, for this we’ll use a repository.

  • Task (Model)
  • TaskRepository
  • TaskViewModel
  • TaskUI (Stateful Widget)

I recommend to look into patterns such as Clean Architecture to properly structure your code into specific layers, but for the sake of simplicity, we’ll smash it all together in this demo.

Create a new package called taskright in the lib package and then create the following 4 files: model.dart repository.dart ui.dart and viewmodel.dart

Step 5: Model & Data Layer

In this step we’ll create a simple task model and a data layer for our app.

Insert into model.dart following code

class Task {
int id;
String title;
String description;
bool done;

Task(this.id, this.title, this.description, this.done);
}

This should be self-explaining. Now insert into repository.dart

import 'model.dart';

class TaskRepository {
final List<Task> _taskList = [
Task(
0,
"Study MVVM",
"In order to avoid ugly state management librares and collect continuously technical debt, I should study proper state management patterns",
false),
];

void addTask(Task task) {
task.id = _taskList.length;
_taskList.add(task);
}

void removeTask(Task task) {
_taskList.remove(task);
}

void updateTask(Task task) {
_taskList[_taskList.indexWhere((element) => element.id == task.id)] = task;
}

Future<List<Task>> loadTasks() async {
// Simulate a http request
await Future.delayed(const Duration(seconds: 2));
return Future.value(_taskList);
}
}

We now have a Repository that supports all CRUD operations for Tasks — in memory (you could instead go on and add a local storage or call your backend). We added a short delay to loadTasks() just to make it behave a bit more realistic, typically you would wait for a network response here.

Step 6: The TaskViewModel and Events

In this step, we’ll define the TaskViewModel and the events it can trigger. Insert into viewmodel.dart following code

import 'package:mvvmtodoapp/mvvm/viewmodel.dart';
import 'package:mvvmtodoapp/task/repository.dart';

import '../mvvm/observer.dart';
import 'model.dart';

class TaskViewModel extends EventViewModel {
TaskRepository _repository;

TaskViewModel(this._repository);

void loadTasks() {
notify(LoadingEvent(isLoading: true));
_repository.loadTasks().then((value) {
notify(TasksLoadedEvent(tasks: value));
notify(LoadingEvent(isLoading: false));
});
}

void createTask(String title, String description) {
notify(LoadingEvent(isLoading: true));
// ... code to create the task
notify(TaskCreatedEvent());
notify(LoadingEvent(isLoading: false));
}
}

class LoadingEvent extends ViewEvent {
bool isLoading;

LoadingEvent({required this.isLoading}) : super("LoadingEvent");
}

class TasksLoadedEvent extends ViewEvent {
final List<Task> tasks;

TasksLoadedEvent({required this.tasks}) : super("TasksLoadedEvent");
}

// should be emitted when
class TaskCreatedEvent extends ViewEvent {
final Task task;

TaskCreatedEvent(this.task) : super("TaskCreatedEvent");
}

Our ViewModel exposes a loadTasks() method which tells whoever calls the ViewModel to its observers to consume following events in the given order:

  1. LoadingEvent(isLoading: true); // ui should show a loading animation
  2. TasksLoadedEvent(tasks: value); // ui should update its data state
  3. LoadingEvent(isLoading: false); // ui should hide loading animation

Whenever you want to introduce a new state to your UI, you define a method in your ViewModel and its matching Event (e.g. TaskCreatedEvent)

Step 7: Build the UI

We’ll build a simple stateful widget that contains a progress spinner and show it when it’s in loading state. Further, it will also have a ListView that renders the data. In Flutter a stateful widget must have its own State object, we’ll use that State object to implement our EventObserver (see step 2). Copy paste into following code into ui.dart

import 'package:flutter/material.dart';
import 'package:mvvmtodoapp/mvvm/observer.dart';
import 'package:mvvmtodoapp/task/repository.dart';
import 'package:mvvmtodoapp/task/viewmodel.dart';

import 'model.dart';

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

@override
State<StatefulWidget> createState() {
return _TaskWidgetState();
}
}

class _TaskWidgetState extends State<TaskWidget> implements EventObserver {
// Consider making TaskRepository() a singleton by using a factory
final TaskViewModel _viewModel = TaskViewModel(TaskRepository());
bool _isLoading = false;
List<Task> _tasks = [];

@override
void initState() {
super.initState();
_viewModel.subscribe(this);
}

@override
void dispose() {
super.dispose();
_viewModel.unsubscribe(this);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("TaskApp 2000"),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_viewModel.loadTasks();
},
child: const Icon(Icons.refresh),
),
body: _isLoading
? const Center(
child: CircularProgressIndicator(),
)
: ListView.builder(
itemCount: _tasks.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(_tasks[index].title),
subtitle: Text(_tasks[index].description),
);
},
)
);
}

@override
void notify(ViewEvent event) {
if (event is LoadingEvent) {
setState(() {
_isLoading = event.isLoading;
});
} else if (event is TasksLoadedEvent) {
setState(() {
_tasks = event.tasks;
});
}
}
}

Check the initState and dispose methods. It’s important that you override those methods and subscribe and unsubscribe from your viewmodel there. Within initState you could also call viewModel.loadTasks() so your data is fetched initially

Replace now the dummy UI code generated in lib/main.dart with following code

import 'package:flutter/material.dart';
import 'package:mvvmtodoapp/task/ui.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: TaskWidget(),
);
}
}

and Ta-Da. You should an App now with a Floating Action Bar that fetches data while indicating a loading state and then, upon successful fetching, update its UI.

Sharing State

A common use case, which forces library users to pull in more libraries, is sharing state between views. The way to accomplish this is by making use of Singletons. A Singleton is a global single instance object, means you can’t have more than one instance of those classes at the same time.

Making your repository a singleton

Your repository fetches data and caches it. If you introduce a cache into your repository, you could make your repository a singleton and every ViewModel can grab the cached value.

Making your viewmodel a singleton

Another way would be to make your ViewModel a singleton, this could be a good technique if your UI is composed of different Stateful components that are rendered at the same time and need to communicate with each other (I’d consider this way of composing the UI a bad practice though, here’s why)

Outlook

I just gave you a toolkit to build 99% of the apps you see on the PlayStore. You won’t need any state management library and the best thing is, you have a beautiful clean build() with no garbage from other libraries, forcing you to wrap your gorgeous UI with bloated, auto-generated, magic code.

You need to make sure that your lifecycle of your UI is handled properly, otherwise, if you forget to unsubscribe your Observer from your Observable, you’re risking a memory leak.

Your ViewModels are 100% unit tested, you can use Mockito to mock your EventObserver and that way you won’t only test the interaction with your underlying model (repository), but also the proper order of how events are emitted! How cool is that (btw, most state management libraries force you to pull in more dependencies for testability).

The pattern I just introduced to you is also not bound to flutter, but it can be implemented on any platform. MVVM is the main architecture pattern in Android Development (where I’ve spent around 10 years of my professional career in).

Everything has it’s advantages and disadvantages, some might not want to deal with handling state themselves — at the cost of being in danger of collecting technical debt due to not being up2date with the used libraries — others would want to keep control over the critical parts of their app, such as state management — at the cost of having another layer of complexity in their app (e.g. ViewEvents).

I’ve been using this pattern now since the early Android days and having worked a lead developer at several projects (big and small teams), I can vouch for the effectiveness, scaleability and maintainability of this pattern. Once you have your base code in place, you will rarely touch it again.
Thanks for reading, if you have any questions or feedback please drop it in the comments.

You can say Hi to me on LinkedIn, X, or GitHub

Source Code

You can find the source code for the code in this article here https://github.com/MrIceman/flutter-demo-mvvm/

For my projects, I’ve extracted all the code I showed here to you into a library and I’m reusing the same concept over and over again. Check estado

PS:

If you’re interested in another state management pattern, build from scratch, together with Clean Architecture, you can read my article here:
https://medium.com/@martinnowosad/flutter-model-view-presenter-clean-architecture-454bb601d755

I haven’t used ChatGPT for this article, so please excuse any grammar or spelling mistakes you might encounter. I’d appreciate it if you could highlight the issues you find or let me know in the comments.

--

--

Passionated Software Developer and Architect who loves writing about Mobile, Backend Development and DevOps. AI needs to be regulated ASAP