view_model 0.13.0
view_model: ^0.13.0 copied to clipboard
Everything is ViewModel. Enjoy automatic lifecycle management, prevent memory leaks, and share state effortlessly. Simple, lightweight, and powerful.
view_model #
The missing ViewModel in Flutter — Everything is ViewModel.
| Package | Version |
|---|---|
| view_model | |
| view_model_annotation | |
| view_model_generator |
Table of Contents #
- Why view_model?
- Installation
- Quick Start
- Features
- Testing
- Common Pitfalls
- Global Configuration
- License
Why view_model? #
Everything can be a ViewModel, and any class can get ViewModels everywhere.
Other solutions force you to choose:
- Global state (shared everywhere)
- Manual providers (boilerplate + BuildContext hell)
view_model gives you both:
- ✅ Everything is ViewModel - Repository, Service, any class
- ✅ Get anywhere, no BuildContext - Access ViewModels from anywhere
- ✅ Isolated by default - Each widget gets its own instance
- ✅ Share when needed - Use
keyfor explicit sharing - ✅ Zero boilerplate - No manual setup
- ✅ Auto lifecycle - Auto create & dispose
Installation #
dependencies:
view_model: ^latest_version
dev_dependencies:
build_runner: ^latest_version
view_model_generator: ^latest_version # Optional, easier to use
Quick Start #
1. Define a ViewModel #
Create a class extending ViewModel. Use update() to notify widgets of changes.
class CounterViewModel extends ViewModel {
int count = 0;
void increment() {
update(() => count++);
}
}
2. Create a Provider #
Define a global provider. This is how widgets find your ViewModel.
final counterProvider = ViewModelProvider<CounterViewModel>(
builder: () => CounterViewModel(),
);
(Tip: Use view_model_generator to skip this step!)
3. Use in Widget #
Use ViewModelStateMixin in your StatefulWidget.
class CounterPage extends StatefulWidget {
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> with ViewModelStateMixin {
@override
Widget build(BuildContext context) {
// Watch the provider. Widget rebuilds when ViewModel updates.
final vm = vef.watch(counterProvider);
return Scaffold(
body: Center(
child: Text('${vm.count}'),
),
floatingActionButton: FloatingActionButton(
onPressed: vm.increment,
child: Icon(Icons.add),
),
);
}
}
Features #
1. Universal Access with Vef #
Key Concept: Vef is a mixin that can be used in any class - not just widgets!
The vef object (ViewModel Execution Framework) is your gateway to accessing ViewModels anywhere in your code.
In Widgets (Built-in)
When you use ViewModelStateMixin, you automatically get vef:
class MyPage extends StatefulWidget {
// ...
}
class _MyPageState extends State<MyPage> with ViewModelStateMixin {
@override
Widget build(BuildContext context) {
final vm = vef.watch(myProvider); // vef is available here
return Text(vm.data);
}
}
In ViewModels (Built-in)
ViewModels already have vef built-in! You can access other ViewModels directly:
// ✅ ViewModel accessing other ViewModels
class CartViewModel extends ViewModel {
void checkout() {
// ViewModels have vef built-in - no need to "with Vef"!
final userVM = vef.read(userProvider);
final paymentVM = vef.read(paymentProvider);
processOrder(userVM.user, paymentVM.method);
}
}
class UserViewModel extends StateViewModel<UserState> {
void updateTheme() {
// Access global settings ViewModel
final settingsVM = vef.read(settingsProvider);
applyTheme(settingsVM.state.theme);
}
}
In Any Class - "Everything is ViewModel"
Philosophy: Any component in your app can be a ViewModel! Repositories, Services, Helpers - they can all extend ViewModel to gain access to other ViewModels without BuildContext.
// ✅ Repository as ViewModel
class UserRepository extends ViewModel {
Future<User> fetch() async {
final authVM = vef.read(authProvider);
return api.get('/user', token: authVM.token);
}
}
// ✅ Service as ViewModel
class AnalyticsService extends ViewModel {
void trackEvent(String event) {
final userVM = vef.read(userProvider);
analytics.log(event, userId: userVM.userId);
}
}
// ✅ Test helper as ViewModel
class TestHelper extends ViewModel {
void setupTestData() {
final authVM = vef.read(authProvider);
authVM.loginAsTestUser();
}
}
ViewModels coordinate with each other:
class UserProfileViewModel extends ViewModel {
final UserRepository _repo;
UserProfileViewModel(this._repo);
Future<void> loadUser() async {
// Access other ViewModels via built-in vef
final authVM = vef.read(authProvider);
final user = await _repo.fetch(); // Repo uses vef internally
// Notify other ViewModels
vef.read(cacheProvider).updateCache(user);
}
}
Why "Everything is ViewModel":
- ✅ No BuildContext needed - access ViewModels anywhere
- ✅ Unified DI - every component uses the same pattern
- ✅ Automatic lifecycle - reference counting works for all
- ✅ Testable - mock any ViewModel in your tests
- ✅ Flexible - choose what fits your architecture
Vef Methods
| Method | Usage |
|---|---|
vef.watch(provider) |
Access + Listen. Returns the instance and subscribes to updates (rebuilding the widget). Safe to use in build() or initState(). |
vef.read(provider) |
Access only. Returns the instance without subscribing. Does NOT trigger rebuilds. Use this in callbacks (like onPressed) or non-widget classes. |
vef.listen(provider, onChanged:) |
Listen only. Subscribe to changes to run side-effects (like showing a dialog) without rebuilding. Auto-disposed. |
vef.watchCached(key:) |
Access an existing cached instance by key (does not create new). |
vef.readCached(key:) |
Read an existing cached instance without listening. |
2. Immutable State (StateViewModel) #
For complex state, it's better to use immutable objects. StateViewModel is designed for this.
// 1. The State Class (with copyWith)
class UserState {
final String name;
final bool isLoading;
UserState({this.name = '', this.isLoading = false});
// Required: copyWith method for immutable updates
UserState copyWith({String? name, bool? isLoading}) {
return UserState(
name: name ?? this.name,
isLoading: isLoading ?? this.isLoading,
);
}
}
// 2. The ViewModel
class UserViewModel extends StateViewModel<UserState> {
UserViewModel() : super(state: UserState());
void loadUser() async {
setState(state.copyWith(isLoading: true)); // Update state
// ... fetch api ...
setState(state.copyWith(isLoading: false, name: 'Alice'));
}
}
Tip: Use code generation tools like freezed or built_value to auto-generate
copyWithmethods.
Listening to Changes
You can listen to specific state changes to trigger side effects (like showing a specific dialog or navigation), without rebuilding the widget.
// Listen to specific property
vef.listenStateSelect(
userProvider,
selector: (state) => state.isLoading,
onChanged: (prev, isLoading) {
if (isLoading) {
showLoadingDialog();
} else {
dismissLoadingDialog();
}
},
);
// Listen to full state
vef.listenState(userProvider, onChanged: (prev, state) {
print('State changed from $prev to $state');
});
3. Dependency Injection (Arguments) #
Often your ViewModel needs external data (like an ID or a Repository). Passing arguments is built-in.
// Define provider expecting an argument (int id)
final userProvider = ViewModelProvider.arg<UserViewModel, int>(
builder: (int id) => UserViewModel(id),
);
// Usage in Widget
final vm = vef.watch(userProvider(123)); // Pass the argument here
4. Instance Sharing (Keys) #
Default Behavior: Isolation
When you call vef.watch(provider), you get a new, private instance of the ViewModel for that widget. If you use the same provider in another widget, it gets a different instance.
Sharing Behavior: Keys
To share a ViewModel instance between widgets (e.g., a "Product Detail" and its "Header"), you must explicitly provide a key.
Scenario: You have a ProductPage and need to share the ProductViewModel with a child widget ProductHeader.
// 1. Define provider with a key derived from an argument
final productProvider = ViewModelProvider.arg<ProductViewModel, String>(
builder: (id) => ProductViewModel(id),
key: (id) => 'product_$id', // Key based on ID
);
// 2. Parent Widget (Page)
class ProductPage extends StatefulWidget {
final String productId;
// ...
build(context) {
// Creates or finds instance with key 'product_123'
final vm = vef.watch(productProvider(productId));
// ...
}
}
// 3. Child Widget (Header)
class ProductHeader extends StatefulWidget {
final String productId;
// ...
build(context) {
// Returns the SAME instance as the parent because the key is the same
final vm = vef.watch(productProvider(productId));
return Text(vm.title);
}
}
5. Automatic Lifecycle #
view_model uses strict reference counting to manage memory.
- Create: The first time a widget accesses a provider via
watch,read, orlisten, the ViewModel is created (if not already cached) and the reference count increments. - Alive: As long as the widget is mounted, it holds a reference to the ViewModel.
watch(provider): Holds a reference AND listens for updates.read(provider): Holds a reference (without listening for updates).listen(provider): Internally callsread, so it ALSO holds a reference.
- Dispose: When the widget is disposed, its reference is removed. When the total reference count drops to 0, the ViewModel is automatically disposed (
dispose()is called).
Exception (Keep Alive): If you set
aliveForever: truein your provider, the ViewModel will NEVER be automatically disposed, even if the reference count hits 0. It behaves like a global singleton.
6. Alive Forever (Global State) #
By default, ViewModels are auto-disposed when not used. However, some ViewModels need to live forever (e.g., User Session, App Settings).
You can achieve this by setting aliveForever: true. It is highly recommended to use a key for such ViewModels to ensure they can be uniquely identified and retrieved globally.
Manual Definition
final appSettingsProvider = ViewModelProvider<AppSettingsViewModel>(
builder: () => AppSettingsViewModel(),
key: 'app_settings', // Specify a global key
aliveForever: true, // This instance will never be disposed
);
Using Generator (Recommended)
@GenProvider(key: 'app_settings', aliveForever: true)
class AppSettingsViewModel extends ViewModel {}
Note: Even if aliveForever is true, the ViewModel is still lazy-loaded. It will be created the first time it is accessed.
7. Architecture Patterns #
Clean Architecture with Vef
Here's how to structure a real app with view_model:
// ============================================
// 1️⃣ Data Layer - Repository as ViewModel
// ============================================
@GenProvider()
class UserRepository extends ViewModel {
final ApiClient _api;
UserRepository(this._api);
// ✅ Repository is ViewModel - can access other ViewModels
Future<User> fetchUser(int id) async {
final authVM = vef.read(authProvider);
return _api.get('/users/$id',
headers: {'Authorization': 'Bearer ${authVM.token}'}
);
}
Future<void> updateUser(User user) async {
final authVM = vef.read(authProvider);
await _api.put('/users/${user.id}', user.toJson(),
headers: {'Authorization': 'Bearer ${authVM.token}'}
);
}
}
// ============================================
// 2️⃣ Domain Layer - Global & Feature ViewModels
// ============================================
@GenProvider(key: 'auth', aliveForever: true)
class AuthViewModel extends StateViewModel<AuthState> {
AuthViewModel() : super(state: AuthState.unauthenticated());
String? get token => state.token;
bool get isAuthenticated => state.isAuthenticated;
Future<void> login(String email, String password) async {
setState(state.copyWith(isLoading: true));
try {
final result = await authService.login(email, password);
setState(AuthState.authenticated(result.token, result.user));
} catch (e) {
setState(state.copyWith(error: e.toString(), isLoading: false));
}
}
void logout() {
setState(AuthState.unauthenticated());
}
}
@GenProvider()
class UserViewModel extends StateViewModel<UserState> {
final UserRepository _repository;
UserViewModel(this._repository) : super(state: UserState.initial());
Future<void> loadUser(int id) async {
setState(state.copyWith(isLoading: true));
try {
// ✅ Repository handles auth internally via vef
final user = await _repository.fetchUser(id);
setState(state.copyWith(user: user, isLoading: false));
} catch (e) {
setState(state.copyWith(error: e.toString(), isLoading: false));
}
}
Future<void> updateProfile(String name) async {
final updated = state.user!.copyWith(name: name);
await _repository.updateUser(updated);
setState(state.copyWith(user: updated));
// Notify other ViewModels
vef.read(profileCacheProvider).invalidate();
}
}
// ============================================
// 3️⃣ Presentation Layer - Widgets
// ============================================
class UserProfilePage extends StatefulWidget {
final int userId;
const UserProfilePage({required this.userId});
@override
State<UserProfilePage> createState() => _UserProfilePageState();
}
class _UserProfilePageState extends State<UserProfilePage>
with ViewModelStateMixin {
@override
void initState() {
super.initState();
// Load user data when page opens
vef.read(userProvider).loadUser(widget.userId);
// Listen for auth changes (e.g., logout)
vef.listenStateSelect(
authProvider,
selector: (state) => state.isAuthenticated,
onChanged: (prev, isAuth) {
if (!isAuth) {
Navigator.of(context).pushReplacementNamed('/login');
}
},
);
}
@override
Widget build(BuildContext context) {
final userVM = vef.watch(userProvider);
final authVM = vef.watch(authProvider);
if (userVM.state.isLoading) {
return Center(child: CircularProgressIndicator());
}
return Scaffold(
appBar: AppBar(
title: Text(userVM.state.user?.name ?? 'Profile'),
actions: [
IconButton(
icon: Icon(Icons.logout),
onPressed: authVM.logout,
),
],
),
body: Column(
children: [
Text('Name: ${userVM.state.user?.name}'),
Text('Email: ${userVM.state.user?.email}'),
ElevatedButton(
onPressed: () => _showEditDialog(userVM),
child: Text('Edit Profile'),
),
],
),
);
}
void _showEditDialog(UserViewModel vm) {
// ... show dialog to edit name
vm.updateProfile(newName);
}
}
Key Takeaways:
- 🔹 "Everything is ViewModel" - Repository, Services, all can extend ViewModel
- 🔹 No BuildContext needed - all components access each other via built-in
vef - 🔹 Repository as ViewModel - handles its own dependencies (like Auth)
- 🔹 ViewModels coordinate - business logic ViewModels use repositories
- 🔹 Widgets use ViewModelStateMixin - access everything via
vef - 🔹 Global state (Auth) uses
aliveForever: truewith akey - 🔹 Unified DI pattern - same pattern across all layers
8. Code Generation (Recommended) #
Writing ViewModelProvider definitions manually is boring. Use @genProvider to automate it.
@genProvider
class MyViewModel extends ViewModel {}
Run dart run build_runner build and it generates the provider for you.
See view_model_generator for details.
Testing #
Widget Tests #
You can mock any ViewModel for testing using setProxy:
testWidgets('displays user data', (tester) async {
final mockVM = MockUserViewModel();
when(mockVM.state).thenReturn(UserState(user: testUser));
// Replace the real implementation with the mock
userProvider.setProxy(
ViewModelProvider(builder: () => mockVM)
);
await tester.pumpWidget(MyApp());
expect(find.text(testUser.name), findsOneWidget);
});
Unit Tests for Repository ViewModels #
You can test repository ViewModels by mocking the providers they depend on:
void main() {
late UserRepository repository;
late MockAuthViewModel mockAuthVM;
setUp(() {
mockAuthVM = MockAuthViewModel();
when(mockAuthVM.token).thenReturn('test-token');
// Mock the auth provider
authProvider.setProxy(
ViewModelProvider(builder: () => mockAuthVM)
);
repository = UserRepository(mockApiClient);
});
tearDown(() {
authProvider.clearProxy();
});
test('fetchUser includes auth token', () async {
// The repository uses vef.read(authProvider) internally
await repository.fetchUser(123);
// Verify token was used
verify(mockApiClient.get(
'/users/123',
headers: {'Authorization': 'Bearer test-token'}
));
});
}
Testing ViewModels that Depend on Other ViewModels #
When testing a ViewModel that uses vef internally, you need to create it through a real Vef context:
// Helper class for tests
class TestVef with Vef {}
void main() {
test('CartViewModel accesses UserViewModel', () {
// Create a Vef context for the test
final testVef = TestVef();
final mockUserVM = MockUserViewModel();
when(mockUserVM.user).thenReturn(testUser);
userProvider.setProxy(
ViewModelProvider(builder: () => mockUserVM)
);
// ✅ Create CartViewModel through Vef so it can access mocked dependencies
final cartVM = testVef.read(cartProvider);
cartVM.checkout();
// Verify the cart accessed the user
verify(mockUserVM.user).called(1);
testVef.dispose();
});
}
Common Pitfalls #
❌ Missing key with aliveForever #
Without a key, each widget creates a separate instance (defeating the purpose):
// ❌ Wrong - creates multiple instances
@GenProvider(aliveForever: true)
class AuthViewModel extends ViewModel {}
// ✅ Correct - single shared instance
@GenProvider(key: 'auth', aliveForever: true)
class AuthViewModel extends ViewModel {}
❌ Using watch() in callbacks #
This creates a new listener on every call:
// ❌ Wrong
ElevatedButton(
onPressed: () {
final vm = vef.watch(provider); // New listener each press!
vm.doSomething();
},
)
// ✅ Correct
ElevatedButton(
onPressed: () {
final vm = vef.read(provider); // No listening
vm.doSomething();
},
)
❌ Forgetting copyWith in State classes #
StateViewModel requires immutable state with copyWith:
// ❌ Wrong - no copyWith
class MyState {
final int count;
MyState(this.count);
}
// ✅ Correct - has copyWith
class MyState {
final int count;
MyState(this.count);
MyState copyWith({int? count}) => MyState(count ?? this.count);
}
// ✅ Better - use freezed/built_value for generation
@freezed
class MyState with _$MyState {
factory MyState({required int count}) = _MyState;
}
Global Configuration #
You can configure global behavior in your main() function.
void main() {
ViewModel.initialize(
config: ViewModelConfig(
// Enable debug logging
isLoggingEnabled: true,
// Custom state equality check (optional)
equals: (prev, current) {
// Use Equatable or custom logic
if (prev is Equatable && current is Equatable) {
return prev == current;
}
return identical(prev, current);
},
// Handle errors in listeners (NEW in v0.13.0)
onListenerError: (error, stackTrace, context) {
// Report to crash analytics
FirebaseCrashlytics.instance.recordError(error, stackTrace);
// Or rethrow in debug mode
if (kDebugMode) {
print('❌ Error in $context: $error');
print(stackTrace);
}
},
// Handle errors during disposal (NEW in v0.13.0)
onDisposeError: (error, stackTrace) {
print('⚠️ Disposal error: $error');
},
),
// Add global observers for navigation/lifecycle events
lifecycles: [
MyViewModelObserver(),
],
);
runApp(MyApp());
}
// Custom observer example
class MyViewModelObserver extends ViewModelLifecycle {
@override
void onCreate(ViewModel viewModel, InstanceArg arg) {
print('✅ Created: ${viewModel.runtimeType}');
}
@override
void onDispose(ViewModel viewModel, InstanceArg arg) {
print('🗑️ Disposed: ${viewModel.runtimeType}');
}
}
New in v0.13.0:
- ✨
onListenerError: Catch errors innotifyListeners()and state listeners - ✨
onDisposeError: Catch errors during resource cleanup - 🎯 Useful for crash reporting and debugging
License #
MIT License - see LICENSE file.