Implementing MVVM with Riverpod in Flutter: A ViewModel‑Based State Management Guide
This article explains how to introduce the ViewModel concept into a Flutter project and use Riverpod 2.0’s NotifierProvider to achieve a clean separation of UI and business logic, illustrated with a complete login‑page example and reusable code snippets.
1. Introduction – After wrapping network requests with dio + riverpod and using simple Provider patterns, the author discovered that UI logic was still mixed with presentation code. Colleagues suggested separating concerns by moving logic into a ViewModel layer, which can be implemented with Riverpod.
2. Concept Overview
Model represents the data structure of the application. Example:
class UserModel {
final String id;
final String name;
final String email;
UserModel({required this.id, required this.name, required this.email});
factory UserModel.fromJson(Map
json) {
return UserModel(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
);
}
}ViewModel holds the state and business logic, exposing data and commands. Example:
class UserViewModel extends ChangeNotifier {
UserModel? _user;
UserModel? get user => _user;
void fetchUserData() async {
var response = await Dio().get("https://mock.apifox.com/m1/4081539-3719383-default/flutter_article/testUser");
Map
? responseObject = response.data;
_user = UserModel.fromJson(responseObject?['data']);
notifyListeners();
}
}View (the UI) listens to the ViewModel and updates automatically. Example:
void main() {
runApp(const MvvmApp());
}
class MvvmApp extends StatelessWidget {
const MvvmApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => UserViewModel(),
child: const MaterialApp(home: UserView()),
);
}
}
class UserView extends StatelessWidget {
const UserView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final viewModel = Provider.of
(context);
return Scaffold(
appBar: AppBar(title: const Text('User')),
body: Center(
child: viewModel.user == null
? ElevatedButton(
onPressed: () => viewModel.fetchUserData(),
child: const Text('Load User'),
)
: Text(
"Hello, ${viewModel.user!.name}, your email is ${viewModel.user!.email}"),
),
);
}
}3. Implementing ViewModel with Riverpod – Riverpod 2.0 adds NotifierProvider . The article shows how to define a provider manually, then how the @riverpod annotation can generate it automatically, simplifying the code.
4. Practical Example – Refactoring a Login Page
Before refactor, the login flow is spread across main.dart , login_page.dart , and a simple LoginInfo model. After refactor, a LoginPageState class holds UI controllers and the optional LoginInfo . A LoginPageVM Notifier (generated by @riverpod ) manages state, performs the login request, and updates the state via copyWith .
class LoginInfo {
final String userName;
final String loginTime;
LoginInfo({required this.userName, required this.loginTime});
factory LoginInfo.fromJson(Map
json) {
return LoginInfo(
userName: json['userName'],
loginTime: json['loginTime'],
);
}
}
class LoginPageState {
final TextEditingController userNameController;
final TextEditingController passwordController;
final LoginInfo? loginInfo;
LoginPageState({this.loginInfo, required this.userNameController, required this.passwordController});
LoginPageState.initial()
: userNameController = TextEditingController(),
passwordController = TextEditingController(),
loginInfo = null;
LoginPageState copyWith({TextEditingController? userNameController, TextEditingController? passwordController, LoginInfo? loginInfo}) {
return LoginPageState(
userNameController: userNameController ?? this.userNameController,
passwordController: passwordController ?? this.passwordController,
loginInfo: loginInfo ?? this.loginInfo,
);
}
}
@riverpod
class LoginPageVM extends _\$LoginPageVM {
@override
LoginPageState build() => LoginPageState.initial();
Future
login() async {
final userName = state.userNameController.text;
final password = state.passwordController.text;
if (userName.isEmpty || password.isEmpty) {
showSnackBar("请输入帐号或密码");
} else {
var response = await Dio().post(
"https://mock.apifox.com/m1/4081539-3719383-default/flutter_article/testLogin",
data: {'username': userName, 'password': password},
);
var data = response.data['data'];
if (response.data['errorCode'] == 200) {
var loginInfo = LoginInfo.fromJson(data);
state = state.copyWith(loginInfo: loginInfo);
showSnackBar("登录成功");
pop(result: loginInfo);
} else {
showSnackBar("登录失败");
}
}
}
}The UI ( LoginPage ) watches the provider, binds the text fields to the controllers in LoginPageState , and triggers loginVM.login() on button press. The home page observes loginInfo via select to rebuild only when that sub‑state changes.
5. Summary – By introducing a ViewModel layer with Riverpod’s NotifierProvider , UI and business logic are cleanly separated, resulting in more maintainable code. Riverpod’s lazy loading and auto‑dispose features keep performance overhead low, and fine‑grained listening with select reduces unnecessary widget rebuilds.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.