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 StatelessWidget that 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 Module that automatically handles the lifecycle of a ViewModel.
  • It abstracts away all the boilerplate of using Provider, ValueListenableBuilder, and disposing the ViewModel.
  • You just implement viewModel() to create your ViewModel and view() to build your UI. The view() method is automatically rebuilt whenever the model changes.

Features

  • Zero Boilerplate: No more manual Provider, ChangeNotifierProvider, ValueListenableBuilder, or dispose() calls in your UI code.
  • Immutable State: Designed for use with immutable state classes. It is required to implement value equality (override == and hashCode) on your state models. This is critical for the ViewModel to 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, or built_value.
  • Optimized Rebuilds: The ViewModel base 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 (or ViewModelModule) 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 == or hashCode as 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, copyWith is still manual but ==/hashCode are 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 even fromJson/toJson if you want.
  • Cons: Requires build_runner and 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 Builder pattern.
  • 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).

Libraries

mvm