MVM (Module-View-Model)
MVM is a lightweight, opinionated package for Flutter that provides a clean architectural pattern for building features. The name MVM is a play on the standard MVVM pattern, where the 'W' visually represents the 'VV' (View-ViewModel).
It integrates provider and ValueListenable to reduce boilerplate and create scalable, self-contained modules.
The core idea is to build features as Modules, where each Module is responsible for creating and providing its own ViewModel, which in turn manages an immutable state object.
Core Concepts
MVM is built around three main classes:
ViewModel<M>
- An abstract class that manages your screen's state.
- It holds an immutable state object of type
M. - It exposes a
ValueListenable<M>for the UI to listen to. - It's smart: it uses the
==operator to perform a value comparison on the state. It only notifies listeners if the new state is actually different from the old one, preventing unnecessary rebuilds.
Module
- An abstract
StatelessWidgetthat represents a self-contained feature. - It can provide its own dependencies (like repositories or services) to its widget subtree using a
MultiProvider.
ViewModelModule<M, VM>
- This is the class you'll use most often. It's a
Modulethat automatically handles the lifecycle of aViewModel. - It abstracts away all the boilerplate of using
Provider,ValueListenableBuilder, and disposing theViewModel. - You just implement
viewModel()to create yourViewModelandview()to build your UI. Theview()method is automatically rebuilt whenever the model changes.
Features
- Zero Boilerplate: No more manual
Provider,ChangeNotifierProvider,ValueListenableBuilder, ordispose()calls in your UI code. - Immutable State: Designed for use with immutable state classes. It is required to implement value equality (override
==andhashCode) on your state models. This is critical for theViewModelto prevent unnecessary rebuilds. - Un-opinionated: You can implement value equality however you want. While you can do it manually, the package works perfectly with popular libraries like
equatable,freezed, orbuilt_value. - Optimized Rebuilds: The
ViewModelbase class relies on the state's==operator for deep value comparison. This prevents UI rebuilds if the new state is identical to the old one. - Scoped Dependencies: Use
Module(orViewModelModule) to provide dependencies that are scoped to just one feature. - Clean & Testable: Promotes a clean separation of concerns:
- Model: What to show.
- ViewModel: How to show and how to react to events.
- View (ViewModelModule): The declarative UI.
How to Use
Here is a simple "Counter" app using the manual approach (no extra dependencies needed).
1. Add Dependencies
First, add mvm and provider to your pubspec.yaml.
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
mvm: ^0.1.0 # Replace with the latest version
provider: ^6.1.5+1 # MVM relies on provider
2. Define Your State (Model)
Create an immutable state class. You must override == and hashCode so the ViewModel can detect when the state has actually changed.
// lib/counter/counter_model.dart
import 'package:flutter/foundation.dart';
@immutable // Recommended for state models
class CounterModel {
final int count;
const CounterModel({required this.count});
// An "initial" state constructor
const CounterModel.initial() : count = 0;
// A helper to create a new instance
CounterModel copyWith({int? count}) {
return CounterModel(count: count ?? this.count);
}
// Manual value equality implementation
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CounterModel &&
runtimeType == other.runtimeType &&
count == other.count;
@override
int get hashCode => count.hashCode;
}
3. Define Your ViewModel
Extend ViewModel and add your business logic. Use the protected state setter to update the state.
// lib/counter/counter_view_model.dart
import 'package:mvm/mvm.dart';
import 'package:mvm_example/counter/counter_model.dart';
class CounterViewModel extends ViewModel<CounterModel> {
// Pass the initial state to the super constructor
CounterViewModel() : super(const CounterModel.initial());
void increment() {
// 'state' is the protected getter/setter for the current state
state = state.copyWith(count: state.count + 1);
}
}
4. Define Your View (Module)
Extend ViewModelModule to create your UI. This class automatically creates, provides, disposes, and listens to your CounterViewModel.
// lib/counter/counter_module.dart
import 'package:flutter/material.dart';
import 'package:mvm/mvm.dart';
import 'package:mvm_example/counter/counter_model.dart';
import 'package:mvm_example/counter/counter_view_model.dart';
// Note the generic order: <Model, ViewModel>
class CounterModule extends ViewModelModule<CounterModel, CounterViewModel> {
const CounterModule({super.key});
@override
CounterViewModel viewModel(BuildContext context) {
// MVM will call this once and provide it to the widget tree.
return CounterViewModel();
}
@override
Widget view(CounterModel model, CounterViewModel viewModel) {
// This 'view' builder is automatically wrapped in a
// ValueListenableBuilder and will rebuild when the 'model' changes.
return Scaffold(
appBar: AppBar(title: const Text('MVM Counter Example')),
body: Center(
child: Text(
'Count: ${model.count}', // Read state directly from the model
style: Theme.of(context).textTheme.headlineMedium,
),
),
floatingActionButton: FloatingActionButton(
onPressed: viewModel.increment, // Call methods on the viewmodel
child: const Icon(Icons.add),
),
);
}
}
Implementing Your State Model (The "M" in MVM)
The most important part of MVM is that your state model (M) must implement value-based equality (override == and hashCode).
This is because theViewModelpurposefully blocks rebuilds if the new state is==` to the old state. This is a feature, not a bug—it prevents your UI from rebuilding when nothing has actually changed.
You can choose any method you prefer. Here are four common ways to define the CounterModel, from simplest to most complex.
Option 1: Manual Implementation (Default)
This is the approach used in the guide above. It requires no extra dependencies.
- Pros: Zero dependencies, very explicit.
- Cons: Boilerplate code, easy to make a mistake in
==orhashCodeas you add more properties.
import 'package:flutter/foundation.dart';
@immutable
class CounterModel {
final int count;
const CounterModel({required this.count});
CounterModel copyWith({int? count}) {
return CounterModel(count: count ?? this.count);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CounterModel &&
runtimeType == other.runtimeType &&
count == other.count;
@override
int get hashCode => count.hashCode;
}
Option 2: Using equatable
This is the most popular mixin-based approach.
- Pros: No code generation,
copyWithis still manual but==/hashCodeare automatic. - Cons: Adds one dependency.
Dependencies:
dependencies:
equatable: ^2.0.5
Model Code:
import 'package:equatable/equatable.dart';
class CounterModel extends Equatable {
final int count;
const CounterModel({required this.count});
CounterModel copyWith({int? count}) {
return CounterModel(count: count ?? this.count);
}
// equatable handles == and hashCode for you
@override
List<Object?> get props => [count];
}
Option 3: Using freezed
This is a very popular code-generation approach.
- Pros: Generates
==,hashCode,copyWith,toString, and evenfromJson/toJsonif you want. - Cons: Requires
build_runnerand a "part" file.
Dependencies:
dependencies:
freezed_annotation: ^2.4.1
dev_dependencies:
build_runner: ^2.4.10
freezed: ^2.5.2
Model Code:
// lib/counter/counter_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'counter_model.freezed.dart'; // Run: dart run build_runner build
@freezed
class CounterModel with _$CounterModel {
const factory CounterModel({
required int count,
}) = _CounterModel;
}
(Note: freezed does not generate an initial constructor. You would pass CounterModel(count: 0) to your ViewModel's super() constructor).
Option 4: Using built_value
This is another powerful code-generation approach, often used in larger projects.
- Pros: Extremely robust, forces immutability, provides a
Builderpattern. - Cons: Very complex, lots of boilerplate, requires
build_runner.
Dependencies:
dependencies:
built_value: ^8.9.2
dev_dependencies:
build_runner: ^2.4.10
built_value_generator: ^8.9.2
Model Code:
// lib/counter/counter_model.dart
import 'package:built_value/built_value.dart';
part 'counter_model.g.dart'; // Run: dart run build_runner build
abstract class CounterModel implements Built<CounterModel, CounterModelBuilder> {
int get count;
CounterModel._();
factory CounterModel([void Function(CounterModelBuilder) updates]) = _$CounterModel;
}
(Note: built_value is much more complex. The ViewModel would use state = state.rebuild((b) => b.count = state.count + 1); instead of copyWith).