
Introduction
Model-View-ViewModel (MVVM) is one of the most popular architectural patterns for organizing Flutter applications. Originally popularized by Microsoft for WPF and Xamarin, MVVM has become a go-to choice for Flutter developers who want clean separation between UI and business logic. By dividing your app into three distinct layers—Model, View, and ViewModel—you create code that’s easier to test, maintain, and scale. Apps built with MVVM at companies like Alibaba and BMW demonstrate how this pattern handles complexity gracefully. In this comprehensive guide, you’ll learn how to implement MVVM in Flutter using both Provider and Riverpod, with practical examples covering data fetching, form handling, and real-world patterns for production apps.
Why Use MVVM in Flutter?
Separation of concerns: Views handle UI rendering only. ViewModels handle state and logic. Models handle data. Each layer has one job.
Testability: ViewModels can be unit tested without Flutter widgets. Mock the data layer and test business logic in isolation.
Reusability: The same ViewModel can power different Views (mobile, tablet, web). Logic isn’t tied to specific widgets.
Team collaboration: UI developers work on Views while backend developers work on ViewModels without stepping on each other.
MVVM Layers Explained
Model Layer
Models represent data structures and data access logic. They’re pure Dart classes with no Flutter dependencies.
// lib/features/users/data/models/user_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_model.freezed.dart';
part 'user_model.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
String? avatarUrl,
@Default(false) bool isVerified,
}) = _User;
factory User.fromJson(Map json) => _$UserFromJson(json);
}
// lib/features/users/data/repositories/user_repository.dart
abstract class UserRepository {
Future getUser(String id);
Future> getUsers();
Future updateUser(String id, Map data);
}
class UserRepositoryImpl implements UserRepository {
final ApiClient _apiClient;
UserRepositoryImpl(this._apiClient);
@override
Future getUser(String id) async {
final response = await _apiClient.get('/users/$id');
return User.fromJson(response.data);
}
@override
Future> getUsers() async {
final response = await _apiClient.get('/users');
return (response.data as List)
.map((json) => User.fromJson(json))
.toList();
}
@override
Future updateUser(String id, Map data) async {
final response = await _apiClient.patch('/users/$id', data: data);
return User.fromJson(response.data);
}
}
ViewModel Layer
ViewModels manage state and expose it to Views. They contain business logic but no UI code.
// lib/features/users/presentation/viewmodels/user_list_viewmodel.dart
import 'package:flutter/foundation.dart';
enum ViewState { initial, loading, loaded, error }
class UserListViewModel extends ChangeNotifier {
final UserRepository _repository;
UserListViewModel(this._repository);
ViewState _state = ViewState.initial;
List _users = [];
String? _errorMessage;
ViewState get state => _state;
List get users => _users;
String? get errorMessage => _errorMessage;
bool get isLoading => _state == ViewState.loading;
bool get hasError => _state == ViewState.error;
Future loadUsers() async {
_state = ViewState.loading;
_errorMessage = null;
notifyListeners();
try {
_users = await _repository.getUsers();
_state = ViewState.loaded;
} catch (e) {
_state = ViewState.error;
_errorMessage = e.toString();
}
notifyListeners();
}
Future refreshUsers() async {
try {
_users = await _repository.getUsers();
_state = ViewState.loaded;
notifyListeners();
} catch (e) {
// Keep existing data on refresh failure
_errorMessage = e.toString();
notifyListeners();
}
}
void clearError() {
_errorMessage = null;
notifyListeners();
}
}
// lib/features/users/presentation/viewmodels/user_detail_viewmodel.dart
class UserDetailViewModel extends ChangeNotifier {
final UserRepository _repository;
final String userId;
UserDetailViewModel(this._repository, this.userId) {
_loadUser();
}
ViewState _state = ViewState.loading;
User? _user;
String? _errorMessage;
ViewState get state => _state;
User? get user => _user;
String? get errorMessage => _errorMessage;
Future _loadUser() async {
try {
_user = await _repository.getUser(userId);
_state = ViewState.loaded;
} catch (e) {
_state = ViewState.error;
_errorMessage = e.toString();
}
notifyListeners();
}
Future updateProfile(String name, String email) async {
try {
_user = await _repository.updateUser(userId, {
'name': name,
'email': email,
});
notifyListeners();
return true;
} catch (e) {
_errorMessage = e.toString();
notifyListeners();
return false;
}
}
}
View Layer
Views are Flutter widgets that render UI and delegate all logic to ViewModels.
// lib/features/users/presentation/views/user_list_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class UserListScreen extends StatelessWidget {
const UserListScreen({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => UserListViewModel(
context.read(),
)..loadUsers(),
child: const _UserListContent(),
);
}
}
class _UserListContent extends StatelessWidget {
const _UserListContent();
@override
Widget build(BuildContext context) {
final viewModel = context.watch();
return Scaffold(
appBar: AppBar(
title: const Text('Users'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: viewModel.isLoading ? null : viewModel.refreshUsers,
),
],
),
body: _buildBody(context, viewModel),
);
}
Widget _buildBody(BuildContext context, UserListViewModel viewModel) {
switch (viewModel.state) {
case ViewState.initial:
case ViewState.loading:
return const Center(child: CircularProgressIndicator());
case ViewState.error:
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
viewModel.errorMessage ?? 'An error occurred',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: viewModel.loadUsers,
child: const Text('Retry'),
),
],
),
);
case ViewState.loaded:
if (viewModel.users.isEmpty) {
return const Center(child: Text('No users found'));
}
return RefreshIndicator(
onRefresh: viewModel.refreshUsers,
child: ListView.builder(
itemCount: viewModel.users.length,
itemBuilder: (context, index) {
final user = viewModel.users[index];
return UserListTile(
user: user,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => UserDetailScreen(userId: user.id),
),
),
);
},
),
);
}
}
}
class UserListTile extends StatelessWidget {
final User user;
final VoidCallback onTap;
const UserListTile({super.key, required this.user, required this.onTap});
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
backgroundImage: user.avatarUrl != null
? NetworkImage(user.avatarUrl!)
: null,
child: user.avatarUrl == null
? Text(user.name[0].toUpperCase())
: null,
),
title: Text(user.name),
subtitle: Text(user.email),
trailing: user.isVerified
? const Icon(Icons.verified, color: Colors.blue)
: null,
onTap: onTap,
);
}
}
MVVM with Riverpod
Riverpod provides a more modern approach with better compile-time safety:
// lib/features/users/presentation/providers/user_providers.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'user_providers.g.dart';
@riverpod
class UserList extends _$UserList {
@override
FutureOr> build() async {
return _fetchUsers();
}
Future> _fetchUsers() async {
final repository = ref.read(userRepositoryProvider);
return repository.getUsers();
}
Future refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_fetchUsers);
}
}
@riverpod
class UserDetail extends _$UserDetail {
@override
FutureOr build(String userId) async {
final repository = ref.read(userRepositoryProvider);
return repository.getUser(userId);
}
Future updateProfile(String name, String email) async {
final repository = ref.read(userRepositoryProvider);
try {
final updated = await repository.updateUser(
state.value!.id,
{'name': name, 'email': email},
);
state = AsyncData(updated);
return true;
} catch (e) {
return false;
}
}
}
// Usage in View
class UserListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(userListProvider);
return Scaffold(
appBar: AppBar(title: const Text('Users')),
body: usersAsync.when(
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) => UserListTile(user: users[index]),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $error'),
ElevatedButton(
onPressed: () => ref.invalidate(userListProvider),
child: const Text('Retry'),
),
],
),
),
),
);
}
}
Recommended Folder Structure
lib/
├── features/
│ └── users/
│ ├── data/
│ │ ├── models/
│ │ │ └── user_model.dart
│ │ └── repositories/
│ │ └── user_repository.dart
│ └── presentation/
│ ├── viewmodels/
│ │ ├── user_list_viewmodel.dart
│ │ └── user_detail_viewmodel.dart
│ ├── views/
│ │ ├── user_list_screen.dart
│ │ └── user_detail_screen.dart
│ └── widgets/
│ └── user_list_tile.dart
├── core/
│ └── network/
└── main.dart
Common Mistakes to Avoid
UI logic in ViewModels: ViewModels should expose state, not decide how to render it. Navigation, dialogs, and snackbars belong in Views.
Directly accessing repositories from Views: Always go through ViewModels. Views should only know about ViewModels.
Forgetting to dispose: When using ChangeNotifier with Provider, ensure proper disposal to avoid memory leaks.
Giant ViewModels: If a ViewModel grows too large, split it into focused ViewModels for specific concerns.
No error handling: Always handle loading, error, and empty states explicitly in both ViewModels and Views.
Conclusion
MVVM in Flutter provides a structured approach to managing state and business logic that scales from simple apps to complex enterprise applications. The separation between Models, ViewModels, and Views makes code testable, maintainable, and easier to reason about. Whether you use Provider’s ChangeNotifier or Riverpod’s code generation approach, the core principle remains the same: keep UI rendering in Views, business logic in ViewModels, and data handling in Models. Start with the pattern that matches your team’s experience—Provider for simpler projects, Riverpod for more complex ones—and evolve as your needs grow. For more on Flutter architecture, check out our guide on Scalable Flutter Project Structure. For state management comparisons, see Provider vs Riverpod vs BLoC.