PraisePraise Emerenini

Mastering MVVM Architecture in Flutter with Riverpod

05 May 2023 - 10 min(s) read

As your Flutter project grows, maintaining a clean and scalable architecture becomes crucial. Without structure, your app can quickly devolve into a mess of business logic mixed with UI code.

This is where MVVM (Model-View-ViewModel) comes in — and when combined with Riverpod, it becomes an incredibly powerful and testable pattern for Flutter development.

In this post, we’ll explore how to build Flutter apps using the MVVM pattern with Riverpod, keeping your business logic isolated from UI components while enjoying reactive and efficient state management.

Flutter Architecture

What is MVVM?

MVVM stands for Model-View-ViewModel — a software architectural pattern that promotes separation of concerns.

  • Model → Handles data, API calls, and repositories.
  • View → The UI layer (widgets) that displays data.
  • ViewModel → Acts as a bridge between the Model and the View. It manages state, business logic, and exposes reactive data streams for the View to consume.

This separation ensures that your UI remains declarative and your logic stays testable.

MVVM + Riverpod Data Flow Diagram

Below is a simple diagram to visualize how data flows in MVVM with Riverpod:

         ┌──────────────────────────┐
         │        User Input        │
         │  (Button, Form, etc.)    │
         └────────────┬─────────────┘
                      │
                      ▼
             ┌─────────────────┐
             │     ViewModel    │  ←─── Riverpod Notifier
             │  (State + Logic) │
             └───────┬─────────┘
                     │
         ┌───────────┴────────────┐
         │         Model           │
         │ (API, Repository, DB)   │
         └───────────┬────────────┘
                     │
                     ▼
             ┌─────────────────┐
             │      View        │  ←─── ConsumerWidget
             │  (Flutter UI)    │
             └─────────────────┘

Why Use MVVM in Flutter?

By default, Flutter doesn’t enforce any specific architecture pattern. While simple apps can get by with direct setState() calls, complex apps need clearer boundaries.

MVVM helps you:

  • Keep UI logic separate from business logic
  • Improve testability (unit test ViewModels easily)
  • Enable code reuse and scalability
  • Simplify reactive data handling with Riverpod providers

Setting Up the Project

First, let’s set up a Flutter project and add Riverpod.

flutter create mvvm_riverpod_demo
cd mvvm_riverpod_demo

flutter pub add flutter_riverpod
flutter pub add http

Folder Structure

Here’s a typical MVVM structure for Flutter:

lib/
  models/
    user.dart
  repositories/
    user_repository.dart
  viewmodels/
    user_viewmodel.dart
  views/
    user_screen.dart
  main.dart

This keeps your code modular and organized.

Step 1: Create the Model

The Model represents the data layer — often fetched from an API or database.

// models/user.dart
class User {
  final int id;
  final String name;
  final String email;

  User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'],
        name: json['name'],
        email: json['email'],
      );
}

Step 2: Create the Repository

The Repository abstracts data fetching. It talks to APIs, databases, or local storage and returns clean data to the ViewModel.

// repositories/user_repository.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/user.dart';

class UserRepository {
  Future<List<User>> fetchUsers() async {
    final res = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users'));

    if (res.statusCode == 200) {
      final List data = json.decode(res.body);
      return data.map((e) => User.fromJson(e)).toList();
    } else {
      throw Exception('Failed to load users');
    }
  }
}

Step 3: Create the ViewModel

The ViewModel acts as a middle layer — it calls the repository, holds state, and exposes it via Riverpod.

// viewmodels/user_viewmodel.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/user.dart';
import '../repositories/user_repository.dart';

final userRepositoryProvider = Provider((ref) => UserRepository());

final userViewModelProvider =
    StateNotifierProvider<UserViewModel, AsyncValue<List<User>>>(
  (ref) => UserViewModel(ref.read(userRepositoryProvider)),
);

class UserViewModel extends StateNotifier<AsyncValue<List<User>>> {
  final UserRepository _repository;

  UserViewModel(this._repository) : super(const AsyncValue.loading()) {
    fetchUsers();
  }

  Future<void> fetchUsers() async {
    try {
      final users = await _repository.fetchUsers();
      state = AsyncValue.data(users);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}

We’re using AsyncValue from Riverpod — which neatly handles loading, data, and error states in one object.

Step 4: Build the View

The View subscribes to the ViewModel using Riverpod’s consumer widgets and renders UI based on the state.

// views/user_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../viewmodels/user_viewmodel.dart';

class UserScreen extends ConsumerWidget {
  const UserScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final usersState = ref.watch(userViewModelProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('MVVM + Riverpod Example')),
      body: usersState.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, _) => Center(child: Text('Error: $err')),
        data: (users) => ListView.builder(
          itemCount: users.length,
          itemBuilder: (_, i) {
            final user = users[i];
            return ListTile(
              leading: CircleAvatar(child: Text(user.name[0])),
              title: Text(user.name),
              subtitle: Text(user.email),
            );
          },
        ),
      ),
    );
  }
}

Step 5: Run the App

Finally, connect it all in main.dart.

// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'views/user_screen.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark(),
      home: const UserScreen(),
    );
  }
}

Run the app:

flutter run

You’ll see a user list fetched from the API and displayed in real-time — powered by Riverpod and structured by MVVM.

Why Riverpod Works So Well with MVVM

  • Unidirectional data flow → ViewModel drives the View
  • Reactivity → UI automatically rebuilds when state changes
  • Simplicity → No need for manual StreamControllers or setState
  • Testability → Each layer can be mocked and tested independently

You can even extend your ViewModels with caching, local persistence, or multiple API sources — all while keeping the same architecture.

Bonus: Testing Your ViewModel

Because all logic is in the ViewModel, you can easily write unit tests:

void main() {
  final repo = UserRepository();
  final viewModel = UserViewModel(repo);

  test('fetchUsers loads user list', () async {
    await viewModel.fetchUsers();
    expect(viewModel.state.value, isNotEmpty);
  });
}

Conclusion

The combination of MVVM and Riverpod offers an elegant and scalable way to structure your Flutter apps.
It enforces separation of concerns, boosts testability, and simplifies UI reactivity.

If you’re building production-grade Flutter apps — start architecting them with MVVM + Riverpod. It will save you countless hours of debugging and refactoring down the road.

5 tag(s) on post "Mastering MVVM Architecture in Flutter with Riverpod"

Staff

Want to collaborate on a future forward project with us?