view_model 0.9.0-dev.3
view_model: ^0.9.0-dev.3 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 and Everything is ViewModel.
Thank Miolin for transferring the permission of the view_model package to me.
- view_model
Everything is ViewModel #
We redefine the "ViewModel" not as a specific MVVM component, but as a Specialized Manager Container equipped with lifecycle awareness.
1. Widget-Centric Architecture In a Flutter App, every action revolves around Pages and Widgets. No matter how complex the logic is, the ultimate consumer is always a Widget. Therefore, binding the Manager's lifecycle directly to the Widget tree is the most logical and natural approach.
2. One Concept, Flexible Scopes You don't need to distinguish between "Services", "Controllers", or "Stores". It's all just a ViewModel. The difference is only where you attach it:
- Global: Attach to the top-level
AppMain. It lives as long as the App (Singleton). - Local: Attach to a Page. It follows the page's lifecycle automatically.
- Shared: Use a unique
key(e.g., ProductID) to share the exact same instance across different Widgets.
3. Seamless Composition & Decoupling
ViewModels can directly depend on and read other ViewModels internally (e.g., a UserVM reading a NetworkVM). However, the ViewModel itself remains Widget-Agnostic—it holds state and logic but does not know about the Widget's existence or hold a BuildContext.
4. Out-of-the-Box Simplicity Compared to GetIt (which requires manual binding glue code) or Riverpod (which involves complex graph concepts), this approach is strictly pragmatic. It provides automated lifecycle management and dependency injection immediately, with zero boilerplate.
5. Beyond Widgets: Custom Binder
Through custom Binder, ViewModels can exist independently of Widgets. Any Dart class can with Binder to become a ViewModel host, enabling use cases like:
- Background Services: Run ViewModel logic in background tasks (e.g., downloads, sync).
- Pure Dart Tests: Test ViewModel interactions without
testWidgets. - Startup Tasks: Execute initialization logic before any Widget is rendered.
This makes ViewModel truly universal—Everything can be a ViewModel, not just UI components. See Custom Binder for details.
Quick Start #
- Watch:
watchViewModel<T>()/watchCachedViewModel<T>() - Read:
readViewModel<T>()/readCachedViewModel<T>() - Global:
ViewModel.readCached<T>() / maybeReadCached<T>() - Recycle:
recycleViewModel(vm) - Effects:
listen(onChanged)/listenState/listenStateSelect
// 1. Define a ViewModel
class CounterViewModel extends ViewModel {
int count = 0;
void increment() => update(() => count++);
}
// 2. Use it in a Widget
class CounterPage extends StatelessWidget with ViewModelStatelessMixin {
@override
Widget build(BuildContext context) {
final vm = watchViewModel<CounterViewModel>(
factory: ViewModelFactory.create(
() => CounterViewModel(),
),
);
return ElevatedButton(
onPressed: vm.increment,
child: Text('${vm.count}'),
);
}
}
Reuse One Instance #
- Key: set
key()in factory → all widgets share the same instance - Tag: set
tag()→ bind newest instance viawatchCachedViewModel(tag) - Any param: pass any
Objectas key/tag (e.g.'user:$id')
Important
When using custom objects as key or tag, ensure they properly implement
== operator and hashCode for correct cache lookup. You can use
third-party libraries like equatable
or freezed to simplify this implementation.
final f = DefaultViewModelFactory<UserViewModel>(
builder: () => UserViewModel(userId: id),
key: 'user:$id',
);
final vm1 = watchViewModel(factory: f);
final vm2 = watchCachedViewModel<UserViewModel>(key: 'user:$id'); // same
Basic Usage #
Adding Dependencies #
First, add view_model to your project's pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
view_model: ^0.8.1 # Please use the latest version
Creating a ViewModel #
Inherit or mixin ViewModel to define business logic. Treat fields as state and call notifyListeners() or update(block) to trigger UI updates. The update(block) is recommended to avoid forgetting to call notifyListeners().
import 'package:view_model/view_model.dart';
import 'package:flutter/foundation.dart'; // For debugPrint
class MySimpleViewModel extends ViewModel {
String _message = "Initial Message";
int _counter = 0;
String get message => _message;
int get counter => _counter;
void updateMessage(String newMessage) {
update(() {
_message = newMessage;
});
}
void incrementCounter() {
update(() {
_counter++;
});
}
@override
void dispose() {
// Clean up resources here, such as closing StreamControllers, etc.
debugPrint('MySimpleViewModel disposed');
super.dispose();
}
}
Convenience Factory Methods (Recommended)
Use static helpers on ViewModelFactory to create factories without
writing boilerplate. These map to DefaultViewModelFactory under the hood.
// 1) Create with optional key/tag/singleton
final f1 = ViewModelFactory.create<CounterViewModel>(
() => CounterViewModel(),
key: 'counter',
tag: 'home',
isSingleton: false,
);
// 2) Singleton
final f2 = ViewModelFactory.withSingleton<CounterViewModel>(
() => CounterViewModel(),
);
// 3) With explicit key
final f3 = ViewModelFactory.withKey<UserViewModel>(
'user:42',
() => UserViewModel(name: 'Alice'),
);
// 4) With explicit tag
final f4 = ViewModelFactory.withTag<SettingsViewModel>(
'settings',
() => SettingsViewModel(),
);
// Use with watch/read APIs
final vm = watchViewModel<CounterViewModel>(factory: f1);
Custom ViewModelFactory
ViewModelFactory is responsible for instantiating ViewModel. Each ViewModel type typically
requires a corresponding Factory.
import 'package:view_model/view_model.dart';
// Assume MySimpleViewModel is defined as above
class MySimpleViewModelFactory with ViewModelFactory<MySimpleViewModel> {
@override
MySimpleViewModel build() {
// Return a new MySimpleViewModel instance
return MySimpleViewModel();
}
}
Factory creates and identifies instances. Use key() to share one instance,
use tag() to group/discover.
| Method/Property | Type | Optional | Description |
|---|---|---|---|
build() |
T |
❌ Must implement | The factory method to create a ViewModel instance. Typically, constructor parameters are passed here. |
key() |
Object? |
✅ Optional | Provides a unique identifier for the ViewModel. ViewModels with the same key will be automatically shared (recommended for cross-widget/page sharing). |
tag() |
Object? |
✅ | Add a tag for ViewModel instance. get tag by viewModel.tag. and it's used by find ViewModel by watchViewModel(tag:tag). |
Note: If you use a custom key object, implement
==andhashCodeto ensure correct cache lookup.
class MyViewModelFactory with ViewModelFactory<MyViewModel> {
// Your custom parameters, usually passed to MyViewModel
final String initialName;
MyViewModelFactory({required this.initialName});
@override
MyViewModel build() {
return MyViewModel(name: initialName);
}
/// The key for sharing the ViewModel. The key is unique, and only one ViewModel instance will be created for the same key.
/// If the key is null, no sharing will occur.
@override
Object? key() => "user-profile";
}
Using ViewModel in Widgets #
Mix ViewModelStatelessMixin or ViewModelStateMixin into Widget to bind a ViewModel.
Use watchViewModel for reactive updates, or readViewModel to
avoid rebuilds. Lifecycle (create, share, dispose) is managed for
you automatically.
ViewModelStatelessMixin
ViewModelStatelessMixin enables StatelessWidget to bind a
ViewModel.
import 'package:flutter/material.dart';
import 'package:view_model/view_model.dart';
/// Stateless widget using ViewModelStatelessMixin.
/// Displays counter state and a button to increment.
// ignore: must_be_immutable
class CounterStatelessWidget extends StatelessWidget
with ViewModelStatelessMixin {
CounterStatelessWidget({super.key});
/// Create and watch the ViewModel instance for UI binding.
late final vm = watchViewModel<CounterViewModel>(
factory: ViewModelFactory.create<CounterViewModel>(
CounterViewModel.new,
),
);
/// Builds UI bound to CounterViewModel state.
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Text('Count: ${vm.state}'),
ElevatedButton(
onPressed: vm.increment,
child: const Text('Increment'),
),
],
),
);
}
}
ViewModelStateMixin
import 'package:flutter/material.dart';
import 'package:view_model/view_model.dart';
// Assume MySimpleViewModel and MySimpleViewModelFactory are defined
class MyPage extends StatefulWidget {
const MyPage({super.key});
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage>
with ViewModelStateMixin<MyPage> {
// 1. Mix in the Mixin
late final MySimpleViewModel simpleVM;
@override
void initState() {
super.initState();
// 2. Use watchViewModel to create and get the ViewModel
// When MyPage is built for the first time, the build() method of MySimpleViewModelFactory will be called to create an instance.
// When MyPage is disposed, if this viewModel has no other listeners, it will also be disposed.
simpleVM =
watchViewModel<MySimpleViewModel>(factory: MySimpleViewModelFactory());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(simpleVM.message)), // Directly access the ViewModel's properties
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('Button pressed: ${simpleVM.counter} times'), // Access the ViewModel's properties
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
simpleVM.updateMessage("Message Updated!"); // Call the ViewModel's method
},
child: const Text('Update Message'),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => simpleVM.incrementCounter(), // Call the ViewModel's method
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Alternative: ViewModelBuilder (no mixin required)
// Example: Using ViewModelBuilder without mixing ViewModelStateMixin
ViewModelBuilder<MySimpleViewModel>(
factory: MySimpleViewModelFactory(),
builder: (vm) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(vm.message),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => vm.updateMessage("Message Updated!"),
child: const Text('Update Message'),
),
],
);
},
)
or Using CachedViewModelBuilder to bind to an existing instance:
// Example: Using CachedViewModelBuilder to bind to an existing instance
CachedViewModelBuilder<MySimpleViewModel>(
shareKey: "shared-key", // or: tag: "shared-tag"
builder: (vm) {
return Row(
children: [
Expanded(child: Text(vm.message)),
IconButton(
onPressed: () => vm.incrementCounter(),
icon: const Icon(Icons.add),
),
],
);
},
)
Side‑effects with listeners #
// In the initState of State or another appropriate method
late VoidCallback _disposeViewModelListener;
@override
void initState() {
super.initState();
// Get the ViewModel instance (usually obtained once in initState or via a getter)
final myVm = watchViewModel<MySimpleViewModel>(factory: MySimpleViewModelFactory());
_disposeViewModelListener = myVm.listen(onChanged: () {
print('MySimpleViewModel called notifyListeners! Current counter: ${myVm.counter}');
// For example: ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Action performed!')));
});
}
@override
void dispose() {
_disposeViewModelListener(); // Clean up the listener to prevent memory leaks
super.dispose();
}
ViewModel Lifecycle #
Important
Both watch (e.g., watchViewModel) and read (e.g., readViewModel) APIs will create a binding and increment the reference count. The ViewModel is disposed only when all bindings are removed.
The lifecycle of a ViewModel is managed automatically based on a reference counting mechanism. This ensures that a ViewModel instance is kept alive as long as it is being used by at least one widget and is automatically disposed of when it's no longer needed, preventing memory leaks.
How It Works: Binder Counting
The system keeps track of how many widgets are "binding" a ViewModel instance.
- Creation & First Binder: When
WidgetAcreates or binds aViewModel(VMA) for the first time (e.g., usingwatchViewModel), the binder count forVMAbecomes 1. - Reuse & More Binder: If
WidgetBreuses the sameVMAinstance (e.g., by usingwatchCachedViewModelwith the same key), the binder count forVMAincrements to 2. - Disposing a Binder: When
WidgetAis disposed, it stops watchingVMA, and the binder count decrements to 1. At this point,VMAis not disposed becauseWidgetBis still using it. - Final Disposal: Only when
WidgetBis also disposed does the binder count forVMAdrop to 0. At this moment, theViewModelis considered unused, and itsdispose()method is called automatically.
This mechanism is fundamental for sharing ViewModels across different parts of your widget tree, ensuring state persistence as long as it's relevant to the UI.
graph TD
subgraph "Time 1: WidgetA creates VMA"
A[WidgetA] -- binds --> VMA1(VMA<br/>Binders: 1)
end
subgraph "Time 2: WidgetB reuses VMA"
A -- binds --> VMA2(VMA<br/>Binders: 2)
B[WidgetB] -- binds --> VMA2
end
subgraph "Time 3: WidgetA is disposed"
A_gone((WidgetA disposed))
B -- binds --> VMA3(VMA<br/>Binders: 1)
end
subgraph "Time 4: WidgetB is disposed"
B_gone((WidgetB disposed))
VMA_gone((VMA disposed))
end
VMA1 --> VMA2 --> VMA3 --> VMA_gone
Initialization #
Before using the view_model package, it's recommended to perform a one-time initialization in your main function. This allows you to configure global settings for the entire application.
void main() {
// Configure ViewModel global settings
ViewModel.initialize(
config: ViewModelConfig(
// Enable or disable logging for all ViewModels.
// It's useful for debugging state changes and lifecycle events.
// Defaults to false.
isLoggingEnabled: true,
// Provide a custom global function to determine if two states are equal.
// This is used by `StateViewModel` and `listenStateSelect` with selectors to decide
// whether to trigger a rebuild.
// If not set, `StateViewModel` uses `identical()` and `listenStateSelect` uses `==`.
equals: (previous, current) {
// Example: Use a custom `isEqual` method for deep comparison
return identical(previous, current);
},
),
// You can also register global lifecycle observers here
lifecycles: [
GlobalLifecycleObserver(),
],
);
runApp(const MyApp());
}
Configuration Options:
isLoggingEnabled: Aboolthat toggles logging for all ViewModel instances. When enabled, you'll see outputs for state changes, creations, and disposals, which is helpful during development.equals: A functionbool Function(dynamic previous, dynamic current)that provides a global strategy for state comparison. It affects:StateViewModel: Determines if the new state is the same as the old one.ViewModel.listen: Decides if the selected value has changed.
lifecycles: A list ofViewModelLifecycleobservers that listen to lifecycle events (e.g.,onCreate,onDispose) for all ViewModels. This is useful for global logging, analytics, or other cross-cutting concerns.
Global ViewModel Lifecycle #
/// Abstract interface for observing ViewModel lifecycle events.
///
/// Implement this interface to receive callbacks when ViewModels are created,
/// watched, unwatched, or disposed. This is useful for logging, analytics,
/// debugging, or other cross-cutting concerns.
///
/// Example:
/// ```dart
/// class LoggingLifecycle extends ViewModelLifecycle {
/// @override
/// void onCreate(ViewModel viewModel, InstanceArg arg) {
/// print('ViewModel created: ${viewModel.runtimeType}');
/// }
///
/// @override
/// void onDispose(ViewModel viewModel, InstanceArg arg) {
/// print('ViewModel disposed: ${viewModel.runtimeType}');
/// }
/// }
/// ```
abstract class ViewModelLifecycle {
/// Called when a ViewModel instance is created.
///
/// Parameters:
/// - [viewModel]: The newly created ViewModel
/// - [arg]: Creation arguments including key, tag, and other metadata
void onCreate(ViewModel viewModel, InstanceArg arg) {}
/// Called when a new binder is added to a ViewModel.
///
/// Parameters:
/// - [viewModel]: The ViewModel being watched
/// - [arg]: Instance arguments
/// - [newBinderId]: Unique identifier for the new binder
void onAddBinder(ViewModel viewModel, InstanceArg arg, String newBinderId) {}
/// Called when a binder is removed from a ViewModel.
///
/// Parameters:
/// - [viewModel]: The ViewModel being unwatched
/// - [arg]: Instance arguments
/// - [removedBinderId]: Unique identifier for the removed binder
void onRemoveBinder(
ViewModel viewModel, InstanceArg arg, String removedBinderId) {}
/// Called when a ViewModel is disposed.
///
/// Parameters:
/// - [viewModel]: The ViewModel being disposed
/// - [arg]: Instance arguments
void onDispose(ViewModel viewModel, InstanceArg arg) {}
}
Stateful ViewModel (StateViewModel<S>) #
Use StateViewModel<S> when you prefer an immutable state object and
updates via setState(newState).
Note
By default, StateViewModel uses identical() to compare state instances
(comparing object references, not content). This means setState() will
trigger a rebuild only when you provide a new state instance. You can
customize this comparison behavior globally via the equals function in
ViewModel.initialize() (see Initialization section).
Defining the State Class #
First, you need to define a state class. It is strongly recommended that this class is immutable,
typically achieved by providing a copyWith method.
// example: lib/my_counter_state.dart
import 'package:flutter/foundation.dart';
@immutable // Recommended to mark as immutable
class MyCounterState {
final int count;
final String statusMessage;
const MyCounterState({this.count = 0, this.statusMessage = "Ready"});
MyCounterState copyWith({int? count, String? statusMessage}) {
return MyCounterState(
count: count ?? this.count,
statusMessage: statusMessage ?? this.statusMessage,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MyCounterState &&
runtimeType == other.runtimeType &&
count == other.count &&
statusMessage == other.statusMessage;
@override
int get hashCode => count.hashCode ^ statusMessage.hashCode;
@override
String toString() => 'MyCounterState{count: $count, statusMessage: $statusMessage}';
}
Creating a Stateful ViewModel #
Inherit from StateViewModel<S>, where S is the type of the state class you defined.
// example: lib/my_counter_view_model.dart
import 'package:view_model/view_model.dart';
import 'package:flutter/foundation.dart';
import 'my_counter_state.dart'; // Import the state class
class MyCounterViewModel extends StateViewModel<MyCounterState> {
// The constructor must initialize the state via super
MyCounterViewModel({required MyCounterState initialState}) : super(state: initialState);
void increment() {
// Use setState to update the state, which will automatically handle notifyListeners
setState(state.copyWith(count: state.count + 1, statusMessage: "Incremented"));
}
void decrement() {
if (state.count > 0) {
setState(state.copyWith(count: state.count - 1, statusMessage: "Decremented"));
} else {
setState(state.copyWith(statusMessage: "Cannot decrement below zero"));
}
}
void reset() {
// You can directly replace the old state with a new State instance
setState(const MyCounterState(count: 0, statusMessage: "Reset"));
}
@override
void dispose() {
debugPrint('Disposed MyCounterViewModel with state: $state');
super.dispose();
}
}
In StateViewModel, you update the state by calling setState(newState). This method replaces the
old state with the new one and automatically notifies all listeners.
Creating a ViewModelFactory #
Create a corresponding Factory for your StateViewModel.
// example: lib/my_counter_view_model_factory.dart
import 'package:view_model/view_model.dart';
import 'my_counter_state.dart';
import 'my_counter_view_model.dart';
class MyCounterViewModelFactory with ViewModelFactory<MyCounterViewModel> {
final int initialCount;
MyCounterViewModelFactory({this.initialCount = 0});
@override
MyCounterViewModel build() {
// Create and return the ViewModel instance in the build method, passing the initial state
return MyCounterViewModel(
initialState: MyCounterState(count: initialCount, statusMessage: "Initialized"));
}
}
Using Stateful ViewModel in Widgets #
Using a stateful ViewModel in a StatefulWidget is very similar to using a stateless ViewModel,
with the main difference being that you can directly access viewModel.state to obtain the current
state object.
// example: lib/my_counter_page.dart
import 'package:flutter/material.dart';
import 'package:view_model/view_model.dart';
import 'my_counter_view_model.dart';
import 'my_counter_view_model_factory.dart';
// MyCounterState will be referenced internally by MyCounterViewModel
class MyCounterPage extends StatefulWidget {
const MyCounterPage({super.key});
@override
State<MyCounterPage> createState() => _MyCounterPageState();
}
class _MyCounterPageState extends State<MyCounterPage>
with ViewModelStateMixin<MyCounterPage> {
late final MyCounterViewModel counterVM;
@override
void initState() {
super.initState();
counterVM = watchViewModel<MyCounterViewModel>(
factory: MyCounterViewModelFactory(initialCount: 10)); // You can pass an initial value
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Stateful ViewModel Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Count: ${counterVM.state.count}', // Directly access the state's properties
style: Theme
.of(context)
.textTheme
.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Status: ${counterVM.state.statusMessage}', // Access other properties of the state
style: Theme
.of(context)
.textTheme
.titleMedium,
),
],
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: () => counterVM.increment(),
tooltip: 'Increment',
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: () => counterVM.decrement(),
tooltip: 'Decrement',
child: const Icon(Icons.remove),
),
const SizedBox(height: 8),
FloatingActionButton.extended(
onPressed: () => counterVM.reset(),
tooltip: 'Reset',
icon: const Icon(Icons.refresh),
label: const Text("Reset"),
),
],
),
);
}
}
Side-effect Listeners #
In addition to the standard listen() method inherited from ViewModel, StateViewModel provides two specialized listeners for reacting to state changes without rebuilding the widget:
-
listenState((previous, current) { ... }): Triggers a callback whenever thestateobject changes. It provides both the previous and the current state, which is useful for comparison or logic that depends on the transition. -
listenStateSelect<T>((state) => state.someValue, (previous, current) { ... }): A more optimized listener that triggers a callback only when a specific selected value within the state changes. This avoids unnecessary reactions when other parts of the state are updated.
// In initState
final myVm = watchViewModel<MyCounterViewModel>(/* ... */);
// Listen to the entire state object
final dispose1 = myVm.listenState((previous, current) {
if (previous.count != current.count) {
print('Counter changed from ${previous.count} to ${current.count}');
}
});
// Listen only to changes in the statusMessage
final dispose2 = myVm.listenStateSelect(
(state) => state.statusMessage,
(previous, current) {
print('Status message changed: $current');
// e.g., ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(current)));
},
);
// Remember to call dispose1() and dispose2() in the widget's dispose method.
Fine-Grained Rebuilds with StateViewModelValueWatcher #
For highly optimized performance, StateViewModelValueWatcher allows you to rebuild a widget based on changes to one or more specific values within your state. This is particularly useful when your widget only depends on a small subset of a larger state object.
It takes a viewModel, a list of selectors, and a builder. The widget rebuilds only when the result of any selector function changes.
How it works:
viewModel: TheStateViewModelinstance to listen to.selectors: A list of functions, where each function extracts a specific value from the state (e.g.,(state) => state.userName).builder: A function that builds the widget, which is called only when any of the selected values change.
Example:
Imagine a UserProfileViewModel with a state containing userName, age, and lastLogin. If a widget only needs to display the user's name and age, you can use StateViewModelValueWatcher to ensure it only rebuilds when userName or age changes, ignoring updates to lastLogin.
// Assume you have a MyCounterViewModel and its state MyCounterState { count, statusMessage }
// Obtain the ViewModel instance (usually with readCachedViewModel if you don't need the whole widget to rebuild)
final myVm = readCachedViewModel<MyCounterViewModel>();
// This widget will only rebuild if `state.count` or `state.statusMessage` changes.
StateViewModelValueWatcher<MyCounterState>(
viewModel: myVm,
selectors: [
(state) => state.count,
(state) => state.statusMessage
],
builder: (state) {
// This Text widget is only rebuilt when count or statusMessage changes.
return Text('Count: ${state.count}, Status: ${state.statusMessage}');
},
)
This approach provides a powerful way to achieve fine-grained control over your UI updates, leading to better performance.
ViewModel → ViewModel dependencies #
The view_model package provides a smart dependency mechanism that allows ViewModels to depend on each other. use readViewModel/readCachedViewModel in ViewModel. The APIs are the same as in ViewModelStateMixin.
Dependency Mechanism
A ViewModel can depend on other ViewModels. For example, ViewModelA might need to access data or logic from ViewModelB.
Even when one ViewModel depends on another, all ViewModel instances are managed directly by the Widget's State. The dependency structure is flat, not nested.
When ViewModelA (which is managed by a Widget) requests ViewModelB as a dependency, ViewModelB is not created "inside" ViewModelA. Instead, ViewModelB is also attached directly to the same Widget.
This means:
- Lifecycle: Both
ViewModelAandViewModelB's lifecycles are tied to theWidget. - Management: The
Widgetis responsible for creating and disposing of all its associatedViewModels. - Relationship:
ViewModelAsimply holds a reference toViewModelB.
Essentially, calling readViewModel or watchViewModel from within a ViewModel is the same as calling it from the Widget. Both access the same central management system.
This flattened approach simplifies lifecycle management and avoids complex, nested dependency chains.
Here's a visual representation of the relationship:
graph TD
Widget
subgraph "Widget's State"
ViewModelA
ViewModelB
end
Widget -- Manages --> ViewModelA
Widget -- Manages --> ViewModelB
ViewModelA -- "Depends on (holds a reference to)" --> ViewModelB
Example
Let's say you have a ViewModelA that depends on ViewModelB.
// ViewModelB
class ViewModelB extends ViewModel {
// ...
}
// ViewModelA
class ViewModelA extends ViewModel {
late final ViewModelB viewModelB;
ViewModelA() {
viewModelB = readCachedViewModel<ViewModelB>();
}
}
When you create ViewModelA in your widget, the dependency mechanism will automatically create and provide ViewModelB.
// In your widget
class _MyWidgetState extends State<MyWidget> with ViewModelStateMixin {
late final ViewModelA viewModelA;
@override
void initState() {
super.initState();
viewModelA = watchViewModel<ViewModelA>(factory: ...);
}
// ...
}
This system allows for a clean and decoupled architecture, where ViewModels can be developed and tested independently.
Pause/Resume Lifecycle #
The pause/resume lifecycle is managed by BinderPauseProviders. By default,
PageRoutePauseProvider, TickerModePauseProvider and AppPauseProvider handle pausing/resuming the Binder based
on route visibility and app lifecycle events, respectively.
When a Binder is paused (e.g., widget navigated away), it stops responding to ViewModel state changes, preventing unnecessary rebuilds. The ViewModel continues to emit notifications, but the paused Binder ignores them. When resumed, the Binder checks for missed updates and rebuilds if necessary.
ValueLevel Rebuilds #
Since the ViewModel updates the entire widget (coarse-grained), if you need more fine-grained updates, here are three methods for your reference.
ValueListenableBuilder #
- For fine-grained UI updates, use
ValueNotifierwithValueListenableBuilder.
final title = ValueNotifier('Hello');
ValueListenableBuilder(
valueListenable: title,
builder: (_, v, __) => Text(v),
);
ObserverBuilder #
- For more dynamic scenarios,
ObservableValueandObserverBuilderoffer more flexibility.
// shareKey for share value cross any widget
final observable = ObservableValue<int>(0, shareKey: share);
observable.value = 20;
ObserverBuilder<int>(observable: observable,
builder: (v) {
return Text(v.toString());
},
)
StateViewModelValueWatcher #
- To rebuild only when a specific value within a
StateViewModelchanges, useStateViewModelValueWatcher.
class MyWidget extends State with ViewModelStateMixin {
const MyWidget({super.key});
late final MyViewModel stateViewModel;
@override
void initState() {
super.initState();
stateViewModel = readViewModel<MyViewModel>(
factory: MyViewModelFactory(),
);
}
@override
Widget build(BuildContext context) {
// Watch value changes on `stateViewModel` and rebuild only when `name` or `age` changes.
// Assuming MyViewModel extends StateViewModel<MyState>
return StateViewModelValueWatcher<MyState>(
viewModel: stateViewModel,
selectors: [(state) => state.name, (state) => state.age],
builder: (state) {
return Text('Name: ${state.name}, Age: ${state.age}');
},
);
}
}
Custom Binder #
Binder is primarily designed for scenarios that do not require a UI. For example, during App startup, you might need to execute some initialization tasks (such as preloading data, checking login status), but no Widgets are displayed yet. In this case, you can create a StartTaskBinder as a host for the ViewModel to run logic.
Binder is the core of the view_model library, responsible for managing ViewModel lifecycle and dependency injection. WidgetMixin is essentially just a wrapper around WidgetBinder.
This means you can use ViewModel in any Dart class, independent of Widgets.
Core Concepts #
- Binder: A generic ViewModel manager. It simulates a host environment, providing methods like
watchViewModel. - WidgetBinder: A subclass of
Binderspecifically adapted for Flutter Widgets, implementing the bridge fromonUpdatetosetState.
Example: StartTaskBinder #
import 'package:view_model/view_model.dart';
/// Binder that runs startup tasks before UI is shown.
/// Typical use: preload data, check auth, warm caches.
class StartTaskBinder with Binder {
late final AppInitViewModel _initVM = watchViewModel(
factory: AppInitViewModelFactory(),
);
/// Triggers startup logic. Call this from main() before runApp.
Future<void> run() async {
await _initVM.runStartupTasks();
}
/// Handles ViewModel updates (logs, metrics, etc.).
@override
void onUpdate() {
// e.g., print status or send analytics event
debugPrint('Init status: ${_initVM.status}');
}
/// Disposes binder and all bound ViewModels.
void close() {
super.dispose();
}
}
// Usage in main():
// final starter = StartTaskBinder();
// await starter.run();
// starter.close();
DevTools Extension #
Enable the DevTools extension for real‑time ViewModel monitoring.
create devtools_options.yaml in root directory of project.
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- view_model: true
