flutter-arquitectura-mvvm-provider

MVVM Pattern in Flutter with Provider

  • 4 min

MVVM is a pattern that separates view, data, and presentation logic.

When an application grows, separating files is not enough: you also need to decide what responsibility belongs to each piece.

In Flutter, the undisputed king (thanks to its similarity with how Provider works) is MVVM (Model - View - ViewModel).

Don’t be scared by the acronym. It’s simply a rule to know where to write each line of code so you don’t mix apples and oranges.

What is each piece?

Let’s map the theoretical concepts to what we already know about Flutter:

  • What is it? Pure data. Classes that represent “things” from the real world.
  • What does it do? Represents data and, depending on the design, can include domain-specific rules. It doesn’t know the UI.
  • In your code: These are your classes with fromJson and toJson.
  • Example: class Product { final String name; final double price; ... }
  • What is it? The User Interface. What the user sees and touches.
  • What does it do? Renders the screen based on the current state and captures user clicks.
  • In your code: These are your Widgets (Screens).
  • Example: class CartScreen extends StatelessWidget { ... }
  • What is it? The intermediary. The brain.
  • What does it do?
    • Stores the State (the list of products, whether it’s loading, etc.).
    • Contains the Business Logic (calculating the total, validating a coupon).
    • Exposes methods for the View to call.
  • In your code: It can be a ChangeNotifier class that we expose via Provider.

Workflow

In this variant, the view receives the state it needs from the ViewModel and sends user actions to it.

User taps a button on the View (onTap).

View calls a function on the ViewModel (provider.add()).

ViewModel processes the logic, updates its internal Models, and notifies (notifyListeners).

View listens to the change and redraws itself automatically.

Practical example: a shopping cart

Let’s see what this looks like in actual code.

Dumb data. Just structure.

class Item {
  final String name;
  final double price;

  Item({required this.name, required this.price});
}
Copied!

This is where the logic lives. If tomorrow you decide the cart has a maximum of 10 items, you change this file. The view doesn’t even know.

import 'package:flutter/material.dart';
import '../models/item.dart';

class CartProvider extends ChangeNotifier { // This is the ViewModel
  // Private state
  final List<Item> _items = [];

  // Getters for the view (Public state)
  List<Item> get items => List.unmodifiable(_items);
  
  double get totalPrice {
    return _items.fold(0, (total, item) => total + item.price);
  }

  // Business Logic (Actions)
  void addItem(Item item) {
    _items.add(item);
    notifyListeners(); // Notifies the View!
  }

  void clearCart() {
    _items.clear();
    notifyListeners();
  }
}
Copied!

The view is “dumb”. It doesn’t calculate sums. It only shows what the ViewModel tells it.

class CartScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Listen to the ViewModel (Watch)
    final cart = context.watch<CartProvider>();

    return Scaffold(
      appBar: AppBar(title: Text("My Cart")),
      body: Column(
        children: [
          // Item list
          Expanded(
            child: ListView.builder(
              itemCount: cart.items.length,
              itemBuilder: (ctx, i) => ListTile(
                title: Text(cart.items[i].name),
                trailing: Text("\$${cart.items[i].price}"),
              ),
            ),
          ),
          
          // Total and Button
          Container(
            padding: EdgeInsets.all(20),
            color: Colors.green[100],
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  "Total: \$${cart.totalPrice}", // The calculation was done by the ViewModel
                  style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
                ),
                ElevatedButton(
                  onPressed: () {
                    // User action -> Call to ViewModel
                    context.read<CartProvider>().clearCart();
                  },
                  child: Text("Pay"),
                )
              ],
            ),
          )
        ],
      ),
    );
  }
}
Copied!

What this separation brings

You might think: “Why create three files if I can do everything in the Widget?”

  1. Testability: You can test your CartProvider (verify the sum works) without having to start an emulator or render pixels.
  2. Maintainability: If the designer changes the screen from red to blue, you only touch the View. If the business changes the tax rule, you only touch the ViewModel.
  3. Reusability: You can use the same CartProvider to show the number of items in a small icon on the HomeScreen.