DEV Community

Cover image for Flutter MVVM Explained With a Real Example
Muhammad Uzair
Muhammad Uzair

Posted on Originally published at uzairxdev.online

Flutter MVVM Explained With a Real Example

Every Flutter developer has shipped a screen like this: a StatefulWidget with an http call tucked inside initState, a isLoading boolean, a try/catch, and three setState calls scattered through a 500-line build method. It works — until the screen grows, the logic spreads, and testing it becomes impossible without booting the whole widget tree. The Flutter MVVM pattern exists to fix exactly this. Model–View–ViewModel moves state and business logic out of your widgets into a dedicated ViewModel, so the UI layer stays dumb, readable, and easy to test.

MVVM is not a Flutter invention — Microsoft introduced it for WPF — but it maps onto Flutter's reactive UI model almost perfectly. In this guide I will explain Flutter MVVM in plain terms, walk through a real product-list example layer by layer, compare it with the alternatives, and show you the pitfalls that make MVVM implementations painful in real projects.

Flutter MVVM in Plain English

MVVM splits your app into three cooperating layers, each with one job:

  • Model — plain Dart classes that describe your data (Product, User, Order). No framework imports, no UI knowledge. These are dumb data holders with fromJson/toJson factories.
  • View — your widgets. The View renders whatever state it is given and forwards user intent (taps, refreshes, text input) to the ViewModel. It contains zero business logic.
  • ViewModel — the brain. It holds the screen's state, runs the business logic, talks to repositories or services for data, and notifies the View when something changes so it can rebuild.

The key relationship: the View observes the ViewModel, never the other way around. The ViewModel knows nothing about BuildContext, widgets, or navigation. That one-way dependency is what makes MVVM code testable — you can unit-test the entire ViewModel with plain Dart tests, no widget harness required.

The Three Layers in a Flutter MVVM Example

Let me make this concrete with a small product catalog screen. Here is the folder layout I use:

lib/features/products/
├── data/
│   ├── product.dart            # Model
│   └── products_repository.dart# Data access (repository pattern)
└── presentation/
    ├── products_view_model.dart # ViewModel
    └── products_page.dart       # View
Enter fullscreen mode Exit fullscreen mode

The Model

The Model is a plain Dart class. It parses JSON and holds data — nothing else:

// lib/features/products/data/product.dart
class Product {
  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.imageUrl,
  });

  final String id;
  final String name;
  final double price;
  final String imageUrl;

  factory Product.fromJson(Map<String, dynamic> json) => Product(
        id: json['id'] as String,
        name: json['name'] as String,
        price: (json['price'] as num).toDouble(),
        imageUrl: json['imageUrl'] as String,
      );
}
Enter fullscreen mode Exit fullscreen mode

The ViewModel

The ViewModel owns the screen's state and the logic to load it. In Flutter MVVM, a ChangeNotifier is the natural ViewModel base — widgets rebuild when it calls notifyListeners():

// lib/features/products/presentation/products_view_model.dart
import 'package:flutter/foundation.dart';
import '../data/product.dart';
import '../data/products_repository.dart';

enum ProductsUiState { initial, loading, loaded, error }

class ProductsViewModel extends ChangeNotifier {
  ProductsViewModel({required ProductsRepository repository})
      : _repository = repository;

  final ProductsRepository _repository;

  ProductsUiState _state = ProductsUiState.initial;
  List<Product> _products = [];
  String? _errorMessage;

  ProductsUiState get state => _state;
  List<Product> get products => _products;
  String? get errorMessage => _errorMessage;

  Future<void> loadProducts() async {
    _state = ProductsUiState.loading;
    notifyListeners();

    try {
      _products = await _repository.fetchProducts();
      _state = ProductsUiState.loaded;
    } catch (_) {
      _errorMessage = 'Could not load products. Pull to try again.';
      _state = ProductsUiState.error;
    }
    notifyListeners();
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice what this ViewModel does and does not do. It exposes an explicit ProductsUiState enum instead of a vague isLoading boolean — one field describes the whole screen, so the UI can never show a spinner and an error at the same time. It catches every failure from the repository and translates it into user-facing state. And it has zero Flutter widget imports beyond foundation.dart for ChangeNotifier. A plain test() block can drive this class end to end with a fake repository.

The View

The View is intentionally thin. It creates the ViewModel, listens to it, and renders state — nothing more. Since Flutter 3.10, ListenableBuilder makes this dependency-free (no state-management package required):

// lib/features/products/presentation/products_page.dart
import 'package:flutter/material.dart';
import 'products_view_model.dart';

class ProductsPage extends StatefulWidget {
  const ProductsPage({super.key, required this.viewModel});

  final ProductsViewModel viewModel;

  @override
  State<ProductsPage> createState() => _ProductsPageState();
}

class _ProductsPageState extends State<ProductsPage> {
  @override
  void initState() {
    super.initState();
    widget.viewModel.loadProducts();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: ListenableBuilder(
        listenable: widget.viewModel,
        builder: (context, _) {
          switch (widget.viewModel.state) {
            case ProductsUiState.loading:
              return const Center(child: CircularProgressIndicator());
            case ProductsUiState.error:
              return Center(
                child: Text(widget.viewModel.errorMessage ?? 'Unknown error'),
              );
            case ProductsUiState.loaded:
              return RefreshIndicator(
                onRefresh: widget.viewModel.loadProducts,
                child: ListView.builder(
                  itemCount: widget.viewModel.products.length,
                  itemBuilder: (context, index) {
                    final product = widget.viewModel.products[index];
                    return ListTile(
                      leading: Image.network(product.imageUrl),
                      title: Text(product.name),
                      subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
                    );
                  },
                ),
              );
            case ProductsUiState.initial:
              return const SizedBox.shrink();
          }
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

That switch on a single enum is the payoff. Every possible screen state is handled, the compiler enforces exhaustiveness, and the View contains no fetching, no error mapping, no business rules. If the designer changes the loading indicator, you touch one file. If the product-loading rules change, you touch the ViewModel and the widget tests still pass untouched.

MVVM vs the Alternatives

Approach Where logic lives Testability Learning curve
Logic in widgets (the default mess) State classes Poor — needs widget tests for logic Zero
MVC-style controllers Controller classes Medium — controllers often hold BuildContext Low
Flutter MVVM ViewModel (ChangeNotifier) High — plain Dart unit tests Low–medium
BLoC Blocs, events, states High — but with much more boilerplate High

MVVM is the pragmatic middle ground. BLoC gives you strict event-driven architecture, which is worth it on very large teams with complex event flows. But for most screens — forms, lists, detail pages — Flutter MVVM delivers 90% of the testability and separation with a fraction of the code. The ViewModel here is 45 lines; the equivalent BLoC with events, states, and a bloc class would easily be three times that.

5 MVVM Pitfalls That Ruin Flutter Apps

1. Letting the ViewModel touch BuildContext. The moment your ViewModel imports material.dart for navigation or dialogs, the one-way dependency is broken. Return results and let the View handle navigation, or emit UI events through a stream the View listens to.

2. A ViewModel per widget. MVVM granularity is one ViewModel per screen (or per cohesive feature), not per button. A dozen micro-ViewModels create a synchronization mess that defeats the pattern's purpose.

3. Vague state fields. isLoading, hasError, errorMessage as independent booleans can describe impossible combinations (loading AND error). A single state enum, like ProductsUiState above, makes invalid states unrepresentable.

4. Putting data access in the ViewModel. The ViewModel should call a repository, not http.get directly. Keep the networking behind an interface so you can swap implementations and mock them in tests — the repository pattern pairs naturally with Flutter MVVM for exactly this reason.

5. Over-applying MVVM to trivial screens. A settings toggle page does not need a ViewModel, a repository, and an enum. MVVM pays off on screens with real logic; applying it to everything is just boilerplate with extra steps.

When to Use Flutter MVVM — and When Not To

Reach for Flutter MVVM when screens have real behavior: loading states, validation, pagination, search, retries, or anything you would want to unit-test. It shines in medium-to-large apps where several developers touch the same screens, because the layer boundaries make ownership clear.

Skip it — or keep it lightweight — for static pages, one-off dialogs, and prototypes where speed matters more than structure. And remember that MVVM is a discipline, not a library: nothing about this pattern requires a specific package. ChangeNotifier plus ListenableBuilder is a complete Flutter MVVM implementation, and you can grow into provider or Riverpod later if constructor injection gets tedious.

If your Flutter screens are already growing into untestable widget soup, MVVM is one of the cheapest refactors with the highest payoff. Pull the logic into a ViewModel, define one explicit state enum per screen, and watch your widget tests shrink to what they should have been all along: rendering assertions.

Building a Flutter app with real complexity — clean architecture, testable state, and a codebase your whole team can navigate? I build production Flutter apps for a living. Get in touch and let's talk about your project.


Originally published at uzairxdev.online

Top comments (1)

Collapse
 
randalschwartz profile image
Randal L. Schwartz •

ViewModel seems redundant with Model when our Model classes can be directly observable. I think it's better in Flutter to think of Views consuming Models (that have business behaviors), and Controllers to bridge the gap in reverse. Yes, classic MVC works better for Flutter. Don't add an unneeded ViewModel... you just end up forwarding a lot of calls in both directions, needlessly being "middle management".