56 MVVM in Riverpod
Q: I’m wondering if MVVM architecture can be used with Riverpod. If so, please give a simple example with explanation and diagrams.
Excellent question! Yes, MVVM (Model-View-ViewModel) architecture works beautifully with Riverpod. In fact, Riverpod’s design naturally supports MVVM patterns. Let me show you how to implement a simple user profile app using MVVM with Riverpod.
56.1 MVVM + Riverpod Example: User Profile App
56.1.1 Model Layer (Data & Business Logic)
// models/user.dart
class User {
final String id;
final String name;
final String email;
final int age;
User({
required this.id,
required this.name,
required this.email,
required this.age,
});
User copyWith({
String? id,
String? name,
String? email,
int? age,
}) {
return User(
id: id ?? this.id,
name: name ?? this.name,
email: email ?? this.email,
age: age ?? this.age,
);
}
}
// services/user_repository.dart
class UserRepository {
// Simulates API calls
Future<User> fetchUser(String id) async {
await Future.delayed(Duration(seconds: 1)); // Simulate network delay
return User(
id: id,
name: 'John Doe',
email: 'john.doe@example.com',
age: 30,
);
}
Future<void> updateUser(User user) async {
await Future.delayed(Duration(milliseconds: 500));
// Simulate API update
print('User updated: ${user.name}');
}
}56.1.2 ViewModel Layer (Riverpod Providers)
// viewmodels/user_viewmodel.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Repository provider
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository();
});
// ViewModel state
class UserViewState {
final User? user;
final bool isLoading;
final String? error;
UserViewState({
this.user,
this.isLoading = false,
this.error,
});
UserViewState copyWith({
User? user,
bool? isLoading,
String? error,
}) {
return UserViewState(
user: user ?? this.user,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
}
// ViewModel (StateNotifier)
class UserViewModel extends StateNotifier<UserViewState> {
final UserRepository _repository;
UserViewModel(this._repository) : super(UserViewState());
// ViewModel methods (business logic for UI)
Future<void> loadUser(String userId) async {
state = state.copyWith(isLoading: true, error: null);
try {
final user = await _repository.fetchUser(userId);
state = state.copyWith(user: user, isLoading: false);
} catch (e) {
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
Future<void> updateUserName(String newName) async {
if (state.user == null) return;
final updatedUser = state.user!.copyWith(name: newName);
// Optimistic update
state = state.copyWith(user: updatedUser);
try {
await _repository.updateUser(updatedUser);
} catch (e) {
// Revert on error
state = state.copyWith(error: e.toString());
}
}
void clearError() {
state = state.copyWith(error: null);
}
}
// ViewModel provider
final userViewModelProvider = StateNotifierProvider<UserViewModel, UserViewState>((ref) {
final repository = ref.watch(userRepositoryProvider);
return UserViewModel(repository);
});56.1.3 View Layer (Flutter Widgets)
// views/user_profile_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class UserProfilePage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewState = ref.watch(userViewModelProvider);
final viewModel = ref.read(userViewModelProvider.notifier);
return Scaffold(
appBar: AppBar(title: Text('User Profile')),
body: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Load user button
ElevatedButton(
onPressed: () => viewModel.loadUser('123'),
child: Text('Load User'),
),
SizedBox(height: 20),
// Content based on state
if (viewState.isLoading)
LoadingView(),
else if (viewState.error != null)
ErrorView(
error: viewState.error!,
onRetry: () => viewModel.clearError(),
),
else if (viewState.user != null)
UserDetailsView(user: viewState.user!),
else
EmptyView(),
],
),
),
);
}
}
class LoadingView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading user...'),
],
),
);
}
}
class ErrorView extends StatelessWidget {
final String error;
final VoidCallback onRetry;
ErrorView({required this.error, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
Icon(Icons.error, color: Colors.red, size: 48),
SizedBox(height: 16),
Text('Error: $error'),
SizedBox(height: 16),
ElevatedButton(
onPressed: onRetry,
child: Text('Retry'),
),
],
),
);
}
}
class UserDetailsView extends ConsumerWidget {
final User user;
UserDetailsView({required this.user});
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewModel = ref.read(userViewModelProvider.notifier);
return Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Name: ${user.name}', style: TextStyle(fontSize: 18)),
SizedBox(height: 8),
Text('Email: ${user.email}'),
SizedBox(height: 8),
Text('Age: ${user.age}'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => _showEditNameDialog(context, viewModel),
child: Text('Edit Name'),
),
],
),
),
);
}
void _showEditNameDialog(BuildContext context, UserViewModel viewModel) {
final controller = TextEditingController(text: user.name);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Edit Name'),
content: TextField(
controller: controller,
decoration: InputDecoration(labelText: 'Name'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'),
),
TextButton(
onPressed: () {
viewModel.updateUserName(controller.text);
Navigator.pop(context);
},
child: Text('Save'),
),
],
),
);
}
}
class EmptyView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Text('No user data. Press "Load User" to fetch data.'),
);
}
}56.2 MVVM Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐
│ MVVM + Riverpod │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ MODEL │ │ VIEWMODEL │ │ VIEW │ │
│ │ │ │ │ │ │ │
│ │ • User │◄───┤ • UserViewModel │◄───┤ • UserProfilePage│ │
│ │ • UserRepository│ │ • UserViewState │ │ • LoadingView │ │
│ │ │ │ │ │ • ErrorView │ │
│ │ Data & Business │ │ UI Logic & │ │ UI Components │ │
│ │ Logic │ │ State Mgmt │ │ & User Input │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
56.3 Riverpod Provider Relationship Diagram
Provider Dependency Graph:
┌─────────────────────────────────┐
│ userRepositoryProvider │
│ ┌─────────────────────────┐ │
│ │ UserRepository │ │
│ │ • fetchUser() │ │
│ │ • updateUser() │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
│
│ Injected via ref.watch()
▼
┌─────────────────────────────────┐
│ userViewModelProvider │
│ ┌─────────────────────────┐ │
│ │ UserViewModel │ │
│ │ • loadUser() │ │
│ │ • updateUserName() │ │
│ │ • clearError() │ │
│ │ │ │
│ │ State: UserViewState │ │
│ │ • user: User? │ │
│ │ • isLoading: bool │ │
│ │ • error: String? │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
│
│ ref.watch() / ref.read()
▼
┌─────────────────────────────────┐
│ View Layer │
│ • UserProfilePage │
│ • LoadingView │
│ • ErrorView │
│ • UserDetailsView │
└─────────────────────────────────┘
56.4 Data Flow Diagram
User Interaction Flow:
1. User taps "Load User"
│
▼
2. View calls: ref.read(userViewModelProvider.notifier).loadUser('123')
│
▼
3. UserViewModel.loadUser() called
│
├─ Sets state: isLoading = true
├─ Calls: _repository.fetchUser('123')
│ │
│ ▼
│ UserRepository.fetchUser() (Model layer)
│ │
│ ▼
│ Returns User data
│
└─ Sets state: user = userData, isLoading = false
│
▼
4. Riverpod notifies all watchers
│
▼
5. UserProfilePage rebuilds
│
▼
6. Shows UserDetailsView with user data
State Management Flow:
┌─────────────────────────────────────────────────────────────┐
│ UserViewState │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Initial: { user: null, isLoading: false, error: null }│ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Loading: { user: null, isLoading: true, error: null }│ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Success: { user: User(), isLoading: false, error: null }││ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Error: { user: null, isLoading: false, error: "..." }│ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
56.5 Key Benefits of MVVM + Riverpod
56.5.1 1. Clear Separation of Concerns
- Model: Pure business logic and data (testable without Flutter)
- ViewModel: UI state management and coordination (testable with mocks)
- View: Pure UI components (focused on presentation)
56.5.2 2. Testability
// Easy to unit test ViewModel
test('should load user successfully', () async {
final mockRepo = MockUserRepository();
final viewModel = UserViewModel(mockRepo);
when(mockRepo.fetchUser('123')).thenAnswer((_) async => testUser);
await viewModel.loadUser('123');
expect(viewModel.state.user, equals(testUser));
expect(viewModel.state.isLoading, false);
});56.5.3 3. Reactive UI
Views automatically update when ViewModel state changes, similar to your experience with reactive programming in R.
56.5.4 4. Dependency Injection
Riverpod handles dependency injection cleanly:
final userViewModelProvider = StateNotifierProvider<UserViewModel, UserViewState>((ref) {
final repository = ref.watch(userRepositoryProvider); // Auto-injected
return UserViewModel(repository);
});This MVVM + Riverpod pattern scales very well for complex Flutter applications and provides excellent separation of concerns, making your code more maintainable and testable.