view_model — State Management, Dependency Injection, and Module Architecture
view_model |
view_model_annotation |
view_model_generator |
Coverage |
|---|---|---|---|
More than state management: view_model is a Flutter architecture for dependency injection, functional-module composition, and automatic lifecycle management.
Model each functional unit—UI state, services, repositories, coordinators, or
domain capabilities—as a ViewModel. ViewModels inject and compose one another
through viewModelBinding, while the binding system resolves only the nodes
whose getters are accessed, reuses instances within its scope, and disposes
them automatically.
dependencies:
view_model: ^1.0.0
Install Skill
npx skills add https://github.com/lwj1994/flutter_view_model --skill view_model
Table of Contents
- Architecture Overview
- Two Core Mixins
- Getting Started
- ViewModel
- ViewModelSpec
- Widget Integration
- viewModelBinding API
- Instance Sharing
- ViewModelBinding in Any Class
- ViewModel-to-ViewModel Dependencies
- Fine-Grained Reactivity
- Pause / Resume
- Lifecycle Details
- Configuration
- Testing
- Code Generation
- DevTools Extension
- view_model vs riverpod
Architecture Overview
The library is organized in three layers:
┌─────────────────────────────────────────────────┐
│ Widget / Consumer Layer │
│ ViewModelStateMixin, ViewModelStatelessMixin │
└───────────────────┬─────────────────────────────┘
│ watch / read
┌───────────────────▼─────────────────────────────┐
│ ViewModelBinding Layer │
│ Bridges consumers to the instance registry. │
│ Both watch() and read() perform binding. │
│ watch() additionally registers a listener. │
│ Manages pause/resume and Zone-based DI. │
└───────────────────┬─────────────────────────────┘
│ getInstance → bind(bindingId)
┌───────────────────▼─────────────────────────────┐
│ Instance Management Layer │
│ InstanceManager ─► Store<T> ─► InstanceHandle │
│ Type-keyed registry. Each handle tracks unique │
│ bindingIds plus source-aware owner paths. │
│ Auto-disposes when bindingIds becomes empty. │
└─────────────────────────────────────────────────┘
Key mechanics:
- Each
ViewModelBinding(typically one per widget) has a uniqueidstring. - Both
watch(spec)andread(spec)obtain or create the ViewModel instance, then bind it for lifecycle management. A visible binding id can have multiple sources (direct, or through one or more parents);onBindruns for the first source andonUnbindfor the last. - When the last source for every binding id is removed (and
aliveForeveris false), the ViewModel is automatically disposed. watchadditionally registers a change listener. Synchronous propagation uses one transaction and deduplicates by binding, so a diamond graph—or a root also watching the leaf directly—updates that binding once.- Every managed ViewModel generation lazily owns a stable internal dependency binding. It supplies the private default key for unkeyed children, keeps resolved children alive for at least the parent's lifetime, and mirrors the parent's current root bindings to those children in real time.
Two Core Mixins
The entire library revolves around two mixins that can be applied to any Dart class:
with ViewModel — Makes a class a managed instance
Any class that mixes in ViewModel gains:
- Lifecycle callbacks (
onCreate,onBind,onUnbind,onDispose) - Listener support (
notifyListeners(),listen(),update()) - Access to other ViewModels via a generation-scoped
viewModelBinding - Automatic disposal registration via
addDispose()
class UserRepository with ViewModel { /* ... */ }
class AnalyticsService with ViewModel { /* ... */ }
class CartViewModel with ViewModel { /* ... */ }
with ViewModelBinding — Makes a class able to access ViewModels
Any class that mixes in ViewModelBinding becomes a binding host — it can create, bind to, and manage ViewModel instances. It's not limited to widgets. Widget mixins like ViewModelStateMixin are simply thin wrappers around ViewModelBinding that bridge onUpdate() to setState().
// A plain Dart class that manages ViewModels
class AppInitializer with ViewModelBinding {
Future<void> run() async {
await viewModelBinding.read(configSpec).load();
await viewModelBinding.read(authSpec).restoreSession();
}
}
// A background service
class SyncService with ViewModelBinding {
void start() {
viewModelBinding.watch(syncSpec).startPeriodicSync();
}
@override
void onUpdate() {
// react to ViewModel changes without any widget
}
}
These two mixins together form the foundation: ViewModel is the managed side, ViewModelBinding is the managing side. Every other API in the library is built on this relationship.
Getting Started
import 'package:view_model/view_model.dart';
// 1. Define a ViewModel
class CounterViewModel with ViewModel {
int count = 0;
void increment() => update(() => count++);
}
// 2. Declare a spec (factory definition)
final counterSpec = ViewModelSpec<CounterViewModel>(
builder: () => CounterViewModel(),
);
// 3. Use in a widget
class CounterPage extends StatefulWidget {
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> with ViewModelStateMixin {
CounterViewModel get vm => viewModelBinding.watch(counterSpec);
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: vm.increment,
child: Text('${vm.count}'),
);
}
}
No root wrapper widget, no ChangeNotifierProvider, no ProviderScope. The mixin gives you viewModelBinding; watch wires up instance creation, binding, listener registration, and disposal.
ViewModel
Basic ViewModel
Mix ViewModel into any class to give it lifecycle awareness and listener support. ViewModel implements Listenable, so it works with Flutter's ListenableBuilder and AnimatedBuilder out of the box.
class TodoViewModel with ViewModel {
final _items = <String>[];
List<String> get items => List.unmodifiable(_items);
void add(String item) {
_items.add(item);
notifyListeners(); // manually notify
}
// update() is a convenience: runs the block, then calls notifyListeners()
void remove(int index) => update(() => _items.removeAt(index));
}
update() preserves synchronous notification for a synchronous block: listeners
have already run when the call returns. If the block returns a Future, the
notification runs after that future completes successfully. A synchronous throw
or failed future is forwarded to the caller and does not notify listeners.
StateViewModel
StateViewModel<T> manages an immutable state object of type T. Internally it uses a StreamController<DiffState<T>> to broadcast (previousState, currentState) pairs. This unlocks listenState and listenStateSelect for selective listening.
class UserState {
final String name;
final int age;
final bool loading;
const UserState({this.name = '', this.age = 0, this.loading = false});
}
class UserViewModel extends StateViewModel<UserState> {
UserViewModel() : super(state: const UserState());
Future<void> load() async {
setState(UserState(loading: true));
final user = await api.fetchUser();
setState(UserState(name: user.name, age: user.age));
}
}
Full-state equality uses the ViewModel constructor's local equals, then the
global ViewModelConfig.equals fallback, and finally identical() when neither
is configured (see Configuration).
setState is the only API that emits a state diff. Calling notifyListeners()
only refreshes broad ViewModel listeners; it does not replay the last diff or
invoke listenState / listenStateSelect again. Selected values use the global
ViewModelConfig.equals fallback when configured, then ==. An explicit
equals passed to listenStateSelect takes priority over the global fallback.
ChangeNotifierViewModel
If you need to extend ChangeNotifier (e.g., to pass the ViewModel directly to AnimatedBuilder or ValueListenableBuilder), use ChangeNotifierViewModel:
class MyViewModel extends ChangeNotifierViewModel {
int value = 0;
void inc() { value++; notifyListeners(); }
}
ViewModelSpec
ViewModelSpec is a declarative factory that tells the system how to build a ViewModel and how to identify it for caching.
// No arguments
final counterSpec = ViewModelSpec<CounterViewModel>(
builder: () => CounterViewModel(),
);
// With a fixed key (shared globally)
final authSpec = ViewModelSpec<AuthViewModel>(
builder: () => AuthViewModel(),
key: 'auth',
aliveForever: true,
);
// With one argument: key and tag are computed from the argument
final userSpec = ViewModelSpec.arg<UserViewModel, String>(
builder: (userId) => UserViewModel(userId),
key: (userId) => 'user-$userId',
);
// Two arguments
final chatSpec = ViewModelSpec.arg2<ChatViewModel, String, int>(
builder: (roomId, limit) => ChatViewModel(roomId, limit),
key: (roomId, limit) => 'chat-$roomId',
);
// arg3 and arg4 are also available
Calling userSpec('abc') returns a ViewModelFactory<UserViewModel> that you can pass to watch / read.
An instance's identity is the combination of the resolved generic ViewModel
type T and its effective key; the builder's runtime result type is not part
of identity, and tag is only a grouping/lookup label. For an ordinary
non-retained instance, when factory key() returns null, the current
ViewModelBinding supplies a private default key, so repeated watch/read
calls for the same T reuse one instance within that binding while different
bindings remain isolated. Set a key when you need to:
- share an instance across bindings;
- distinguish multiple instances of the same
Tin one binding; or - give shared instances a stable identity across all resolving bindings.
A key does not keep an instance alive; retention is controlled separately by
aliveForever. Every aliveForever spec must use an explicit key, whether it
is resolved by a root binding or another ViewModel. An unkeyed retained spec
throws ViewModelError before its builder runs, and the Store enforces the
same invariant for lower-level factories.
In debug mode, resolving different specs with the same T and effective key
from one binding emits a warning: instance identity ignores the builder, so the
second builder will not run. Give logically different specs distinct keys.
Internally, ViewModelSpec extends ViewModelFactory<T>, which defines:
build()— creates the instancekey()— cache key (same resolvedT+ same key = same identity)tag()— logical grouping labelaliveForever()— whether to skip auto-disposal
Widget Integration
ViewModelStateMixin
The primary way to use ViewModels in widgets. Mix it into State<T>:
class _MyPageState extends State<MyPage> with ViewModelStateMixin {
MyViewModel get vm => viewModelBinding.watch(mySpec);
@override
Widget build(BuildContext context) {
return Text(vm.data);
}
}
The mixin:
- Creates a
WidgetViewModelBindingwhoseonUpdate()callssetState(). - Registers three default
PauseProviders (route, ticker mode, app lifecycle). - Disposes everything (unbinds all handles) in
State.dispose().
ViewModelStatelessMixin
Mix into StatelessWidget for lightweight usage. The mixin creates a custom Element that owns the WidgetViewModelBinding:
class MyWidget extends StatelessWidget with ViewModelStatelessMixin {
MyViewModel get vm => viewModelBinding.watch(mySpec);
MyWidget({super.key});
@override
Widget build(BuildContext context) => Text(vm.data);
}
Caveat: if the same widget instance is mounted in multiple locations simultaneously, this won't work correctly. Prefer
ViewModelStateMixinwhen in doubt.
viewModelBinding API
viewModelBinding is the accessor provided by ViewModelStateMixin, ViewModelStatelessMixin, the ViewModel mixin, or any class that mixes in ViewModelBinding. It exposes ViewModelBindingInterface with these methods:
watch and read (recommended)
Normal application code should resolve ViewModels through a stable spec. Both
APIs create the instance when absent, bind the current ViewModelBinding,
and observe handle disposal (including force-recycle). watch additionally listens to the
ViewModel's own notifyListeners():
| API | Creates if absent? | Binds when found? | VM notifyListeners() |
Handle disposal |
|---|---|---|---|---|
watch(spec) |
Yes | Yes | Yes | Yes |
read(spec) |
Yes | Yes | No | Yes |
// In initState or build — want rebuilds when ViewModel changes
final vm = viewModelBinding.watch(spec);
// In an event handler — just need to call a method, no rebuild needed
void _onTap() {
viewModelBinding.read(spec).doSomething();
}
Cached lookup (advanced)
Caution
Do not use cached lookup as a substitute for spec-based dependency resolution. It reaches into instances that must already have been created by another path, couples the caller to cache identity/order, and cannot create a missing dependency. Use it only when that cross-owner cache query is intentional and you understand its lifecycle consequences.
| API | Creates if absent? | Binds when found? | VM notifyListeners() |
Handle disposal |
|---|---|---|---|---|
watchCached(key/tag) |
No | Yes | Yes | Yes |
readCached(key/tag) |
No | Yes | No | Yes |
maybeWatchCached(key/tag) |
No; returns null |
Yes | Yes | Yes |
maybeReadCached(key/tag) |
No; returns null |
Yes | No | Yes |
watchCachesByTag(tag) |
No; returns all matches | Yes | Yes | Yes |
readCachesByTag(tag) |
No; returns all matches | Yes | No | Yes |
All six APIs are lookup-only. The non-maybe single-result methods throw on a
miss; maybe* returns null; tag-batch methods return every match. A
single-result lookup by tag can be ambiguous and follows cache creation order,
so use the batch APIs when several instances may share that tag.
listen / listenState / listenStateSelect
Fire-and-forget listeners that are automatically cleaned up when the binding disposes. These use read internally (bind without triggering widget rebuild) and then attach custom callbacks:
// General change callback
viewModelBinding.listen(authSpec, onChanged: () {
print('auth changed');
});
// StateViewModel: full state diff
viewModelBinding.listenState(userSpec, onChanged: (UserState? prev, UserState curr) {
print('user state changed');
});
// StateViewModel: selected property with a custom equality rule
viewModelBinding.listenStateSelect(
userSpec,
selector: (UserState s) => s.name,
equals: (previous, current) => previous == current,
onChanged: (String? prevName, String currName) {
print('name changed to $currName');
},
);
Use listenStateSelect without equals for the global
ViewModelConfig.equals fallback, or == when the global comparator is null.
Pass its optional strongly typed equals when this selector needs a local rule;
the local rule takes priority over the global fallback.
For field-level updates, prefer read plus selector-based listeners. Avoid
pairing listenStateSelect with watch on the same ViewModel, or you'll keep
the broad ViewModel listener and lose the point of selective updates.
Lifecycle Control
Routine cleanup is automatic when a binding is disposed. The explicit lifecycle controls are:
recycle(vm)is an advanced escape hatch with dangerous global impact: it removes every owner and force-disposes the shared cached instance, including analiveForeverinstance. Use it only when that global effect is explicitly intended. The nextwatch/readcreates a fresh instance.
There is no in-place instance replacement API. To obtain a distinct instance,
use a new explicit key. If replacing the shared cached generation globally is
intentional, call recycle(vm) and let resolver getters call
watch(spec)/read(spec) again. This creates a new handle and dependency tree
through the normal cache-miss path instead of migrating relationships between
objects.
Custom selector equality is the optional equals argument on
listenStateSelect.
After
recycle, the old object is disposed. Every consumer—especially other owners of a shared instance—must resolve the ViewModel through a resolver getter that callswatch/readon every access. Owners are notified, and the getter's next access misses the removed cache entry and creates the fresh instance normally. A long-lived field keeps pointing at the disposed object and can cause leaks or failures.
MyViewModel get vm => viewModelBinding.watch(mySpec); // resolve on each access
// Advanced escape hatch only; this affects every owner:
void resetGlobally() => viewModelBinding.recycle(vm);
// Do not keep using the old value; the next `vm` getter access resolves fresh.
Instance Sharing
key-based Sharing
When a ViewModelSpec<T> has a key, any binding that resolves the same T
with an equal key gets the same instance. Each binding contributes a source
to its bindingId; direct and parent-propagated sources can coexist. The
instance stays alive until every source for every binding id has been removed.
final spec = ViewModelSpec<CounterViewModel>(
builder: () => CounterViewModel(),
key: 'shared-counter',
);
// Widget A binds → bindingIds = ['A#123']
viewModelBinding.watch(spec);
// Widget B binds → bindingIds = ['A#123', 'B#456']
viewModelBinding.watch(spec);
For aliveForever: false, when factory key() returns null, the binding
supplies a private default key. This gives one instance per resolved generic
ViewModel type T within that binding, isolated from other bindings. To create
multiple instances of the same T in one binding, give their specs distinct
keys. An aliveForever instance cannot use this private default.
tag-based Lookup
tag is a grouping label. Multiple instances can share the same tag:
final spec = ViewModelSpec<ItemVM>(
builder: () => ItemVM(),
tag: 'active-items',
);
aliveForever Retention
Set aliveForever: true to skip automatic disposal when the handle's
bindingIds becomes empty. The instance remains cached until it is explicitly
force-disposed with recycle, ViewModel.reset() is called, or the process
ends:
final authSpec = ViewModelSpec<AuthViewModel>(
builder: () => AuthViewModel(),
key: 'auth',
aliveForever: true,
);
An aliveForever parent transitively retains children already resolved by its
generation scope. Every aliveForever spec must use an explicit key at both
root and nested resolution sites, giving the retained cache a globally
reachable identity.
ViewModelBinding in Any Class
ViewModelBinding is not just for widgets — any Dart class can mix it in to gain the full viewModelBinding API (watch, read, listen, etc.). Widget mixins like ViewModelStateMixin are simply thin wrappers around ViewModelBinding that bridge onUpdate() to setState().
App initialization:
class AppBootstrap with ViewModelBinding {
Future<void> run() async {
final config = viewModelBinding.read(configSpec);
await config.load();
final auth = viewModelBinding.read(authSpec);
await auth.restoreSession();
}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final bootstrap = AppBootstrap();
await bootstrap.run();
bootstrap.dispose(); // unbind when done
runApp(MyApp());
}
Background services:
class SyncService with ViewModelBinding {
void start() {
viewModelBinding.watch(syncSpec).startPeriodicSync();
}
@override
void onUpdate() {
// react to ViewModel changes without any widget
print('sync state changed');
}
}
Pure Dart tests (no testWidgets needed):
test('counter increments', () {
final binding = ViewModelBinding();
final vm = binding.watch(counterSpec);
expect(vm.count, 0);
vm.increment();
expect(vm.count, 1);
binding.dispose();
});
You can override onUpdate(), onPause(), onResume() in your class. You can also add custom PauseProviders via addPauseProvider().
ViewModel-to-ViewModel Dependencies
Inside a ViewModel, viewModelBinding is stable for that parent object
generation. Its private default key gives unkeyed children a stable identity
even when the parent's root owners change. Expose nested ViewModels through
resolver getters that call watch/read on every access; this remains necessary
after explicit recycle or an asynchronous lifecycle race:
class OrderViewModel with ViewModel {
CartViewModel get cart => viewModelBinding.read(cartSpec);
UserViewModel get user => viewModelBinding.read(userSpec);
double get total => cart.items.fold(0, (sum, i) => sum + i.price);
}
Prefer a getter over late final, a constructor-cached field, or ??= so the
next access can resolve a new generation after recycle.
Reactive dependencies use watch. A child update invokes
parent.onDependencyNotify(child), then notifies the parent. The propagation
transaction updates each watching binding at most once:
class DashboardViewModel with ViewModel {
AuthViewModel get auth => viewModelBinding.watch(authSpec);
}
Side-effect dependencies with listen:
class ChatViewModel with ViewModel {
ChatViewModel() {
viewModelBinding.listenState(authSpec, onChanged: (prev, curr) {
if (curr.isLoggedOut) clearMessages();
});
}
}
Getter declarations alone create nothing; a dependency is created or reused only when its getter is evaluated. Once resolved, the parent generation owns a dependency edge to the child, so the child's lifetime cannot be shorter than that parent's lifetime. When root B starts or stops owning a shared parent, B is also added to or removed from every already-resolved child in real time.
Direct and parent paths are source-aware: if one root owns the same keyed child
both directly and through one or more parents, releasing one path cannot remove
the others. Nested unkeyed children use the parent's private default key and do
not switch identity during a natural root-owner handoff. This unkeyed behavior
is only valid when aliveForever is false: every retained root or child must
use an explicit key.
Child ViewModel lifecycle diagram
The following example has root bindings A and B sharing one ordinary keyed
parent (aliveForever: false). The parent dependency binding belongs to the
current parent object generation; it does not belong to either individual root:
sequenceDiagram
participant A as Root Binding A
participant B as Root Binding B
participant P as Parent VM generation
participant D as Parent dependency binding
participant C as Child VM
A->>P: watch/read(parentSpec)
P->>D: Lazily create generation scope
P->>D: read/watch(childSpec)
D->>C: Add parent → child lifetime edge
D->>C: Mirror A binding source
B->>P: watch/read(the same keyed parent)
D->>C: Mirror B binding source in real time
A-->>P: dispose / unbind
D-->>C: Remove only the A source
Note over P,C: B still owns the parent; the same parent and child generations stay alive
B-->>P: dispose / last root leaves
P-->>D: Dispose parent generation scope
D-->>C: Release the parent edge and B source
Note over C: Dispose only if no direct or other-parent owner remains
A child may outlive its parent when another direct or parent owner remains, but
it cannot die before a parent generation that owns it. Adding or removing A/B
only updates propagated sources; it never switches the private key of an
unkeyed child while that parent generation remains alive. If the parent itself
is aliveForever, the last root only removes its propagated source: the parent
generation keeps the child alive until recycle or ViewModel.reset().
Fine-Grained Reactivity
StateViewModelSelector
For new code, prefer one strongly typed selector and selected builder value.
Use a Dart record to select several fields as one update boundary. The selected
value uses global ViewModelConfig.equals when configured and otherwise ==;
pass a typed equals to override that fallback locally:
StateViewModelSelector<UserState, ({String name, int age})>(
viewModel: vm,
selector: (state) => (name: state.name, age: state.age),
builder: (context, value) => Text('${value.name}, ${value.age}'),
)
StateViewModelValueWatcher
This compatibility widget accepts a list of untyped selectors and only rebuilds when at least one selected value changes:
class _MyPageState extends State<MyPage> with ViewModelStateMixin {
// Use read — the ValueWatcher handles its own subscriptions internally.
// Avoid watch here, or the whole ViewModel will still trigger rebuilds.
UserViewModel get vm => viewModelBinding.read(userSpec);
@override
Widget build(BuildContext context) {
return StateViewModelValueWatcher<UserState>(
viewModel: vm,
selectors: [(s) => s.name, (s) => s.age],
builder: (state) => Text('${state.name}, age ${state.age}'),
);
}
}
Internally, each selector is wrapped into a listenStateSelect call on the
ViewModel. The widget only rebuilds when at least one selector's output differs
from its previous value according to global ViewModelConfig.equals, or ==
when the global comparator is null. To keep updates truly fine-grained, read
the ViewModel with read and let the selector mechanism drive rebuilds instead
of also using watch.
Pause / Resume
When a widget is not visible, there's no point rebuilding it. The library automatically pauses ViewModel update delivery in three scenarios:
| Provider | Pauses when | Resumes when |
|---|---|---|
PageRoutePauseProvider |
Another route is pushed on top (didPushNext) |
The covering route pops (didPopNext) |
TickerModePauseProvider |
TickerMode is false (e.g., hidden tab in TabBarView) |
TickerMode is true again |
AppPauseProvider |
App enters AppLifecycleState.hidden |
App enters AppLifecycleState.resumed |
The PauseAwareController aggregates all providers: if any provider signals "pause", the binding is paused. While paused, incoming notifyListeners() calls set a _hasMissedUpdates flag instead of calling onUpdate(). When all providers signal "resume", one catch-up onUpdate() fires.
Setup: for PageRoutePauseProvider to work, register the route observer:
MaterialApp(
navigatorObservers: [ViewModel.routeObserver],
)
You can add custom pause providers:
class MyCustomPauseProvider with ViewModelBindingPauseProvider {
void onScreenOff() => pause();
void onScreenOn() => resume();
}
// In initState or any ViewModelBinding host
viewModelBinding.addPauseProvider(myProvider);
Lifecycle Details
Reference Counting (Binding)
Each InstanceHandle exposes unique bindingIds, while internally retaining a
source-aware set for every id. Both watch and read add an ownership source;
watch additionally registers a ViewModel listener. onBind(id) runs only
when the first source for an id arrives, and onUnbind(id) runs only when its
last source leaves.
read from Binding A → bind('A#123') → bindingIds = ['A#123']
watch from Binding B → bind('B#456') → bindingIds = ['A#123', 'B#456']
Binding A disposes → unbind('A#123') → bindingIds = ['B#456']
Binding B disposes → unbind('B#456') → bindingIds = [] → auto-dispose
If A also reaches the same keyed instance through a parent, disposing A's direct path leaves the propagated A source intact. Auto-disposal requires all direct and parent sources to be gone.
The full lifecycle sequence:
ViewModelFactory.build()
│
▼
onCreate(arg) ← InstanceHandle created, stored in Store<T>
│
▼
onBind(arg, bindingId) ← a ViewModelBinding binds (via watch or read)
│
▼
[active: notifyListeners(), setState(), etc.]
│
▼
onUnbind(arg, bindingId) ← unbound by dispose or recycle
│
▼
(if bindingIds is empty and not aliveForever)
│
▼
onDispose(arg) ← InstanceHandle nullifies the instance
│
▼
dispose() ← your cleanup code runs
Resource Cleanup
Register cleanup callbacks with addDispose. They run in order during onDispose:
class StreamViewModel with ViewModel {
StreamViewModel() {
final subscription = someStream.listen((_) => notifyListeners());
addDispose(subscription.cancel);
}
}
You can also override dispose() directly:
@override
void dispose() {
_controller.close();
super.dispose();
}
ViewModelLifecycle Observer
Register global observers to monitor all ViewModel lifecycle events (creation, binding, unbinding, disposal):
class DebugLifecycle extends ViewModelLifecycle {
@override
void onCreate(ViewModel vm, InstanceArg arg) {
print('[+] ${vm.runtimeType} created (key=${arg.key})');
}
@override
void onBind(ViewModel vm, InstanceArg arg, String bindingId) {
print('[~] ${vm.runtimeType} bound by $bindingId');
}
@override
void onUnbind(ViewModel vm, InstanceArg arg, String bindingId) {
print('[~] ${vm.runtimeType} unbound by $bindingId');
}
@override
void onDispose(ViewModel vm, InstanceArg arg) {
print('[-] ${vm.runtimeType} disposed');
}
}
void main() {
ViewModel.initialize(lifecycles: [DebugLifecycle()]);
runApp(MyApp());
}
You can also add/remove lifecycle observers dynamically:
final remove = ViewModel.addLifecycle(myObserver);
// later
remove();
Configuration
Call ViewModel.initialize() once at app startup. Subsequent calls are ignored.
void main() {
ViewModel.initialize(
config: ViewModelConfig(
// Enable debug logging
isLoggingEnabled: true,
// Global equality fallback (default: null)
// Full state ultimately falls back to identical(); selectors to ==.
equals: (a, b) => a == b,
// Global error handler for listener and disposal errors
onError: (error, stackTrace, type) {
crashReporter.report(error, stackTrace);
},
),
lifecycles: [DebugLifecycle()],
);
runApp(MyApp());
}
Equality priority: full state uses local constructor equals → global
ViewModelConfig.equals → identical(). A selected value uses explicit
selector equals → global ViewModelConfig.equals → ==. The global
comparator defaults to null. If it delegates to ==, every state and selected
value type it receives must support the intended == semantics; state classes
should also implement matching hashCode.
Testing
Run ViewModel tests single-threaded and in runner order:
flutter test --concurrency=1
The registry, global configuration, lifecycle observers, reset state, and
legacy spec proxies are process-global mutable state. Do not enable parallel
test files, sharding, or concurrent test groups for this package.
dart_test.yaml enforces concurrency: 1 for repository runs.
ViewModelSpec supports proxy overrides for testing. For scoped overrides,
overrideWith returns an idempotent restore callback, and runWithOverride
restores automatically after synchronous or asynchronous success/failure.
Nested and out-of-order restores are safe. Each runWithOverride invocation
uses its own async Zone, so overlapping asynchronous bodies do not observe one
another's scoped override selection. Normal key-based ViewModel instance
sharing still applies after factory selection. The older setProxy /
clearProxy pair remains available as a global legacy fallback:
final userSpec = ViewModelSpec<UserViewModel>(
builder: () => UserViewModel(),
key: 'user',
);
test('with mock', () {
userSpec.setProxy(ViewModelSpec(
builder: () => MockUserViewModel(),
key: 'user',
));
final binding = ViewModelBinding();
final vm = binding.watch(userSpec);
expect(vm, isA<MockUserViewModel>());
binding.dispose();
userSpec.clearProxy();
});
Parameterized specs (ViewModelSpec.arg, .arg2, etc.) also support setProxy / clearProxy.
await userSpec.runWithOverride(mockUserSpec, () async {
final vm = binding.read(userSpec);
expect(vm, isA<MockUserViewModel>());
}); // prior override is restored here, even if the body throws
Call ViewModel.reset() between isolated runtime tests when needed.
It force-disposes every cached instance (including aliveForever instances),
clears lifecycle/configuration and DevTools tracking state, and permits clean
re-initialization.
For widget-free testing, just use a plain ViewModelBinding:
test('interaction test', () {
final binding = ViewModelBinding();
final cart = binding.watch(cartSpec);
final checkout = binding.watch(checkoutSpec);
cart.addItem(Item('test'));
expect(checkout.total, greaterThan(0));
binding.dispose();
});
Code Generation
The optional view_model_generator package auto-generates ViewModelSpec definitions from annotations:
dev_dependencies:
build_runner: ^2.0.0
view_model_generator: ^latest
part 'counter_view_model.vm.dart';
@GenSpec
class CounterViewModel with ViewModel {
int count = 0;
void increment() => update(() => count++);
}
dart run build_runner build
Generated:
// counter_view_model.vm.dart
final counterViewModelSpec = ViewModelSpec<CounterViewModel>(
builder: () => CounterViewModel(),
);
The generator supports ViewModels with up to 4 constructor parameters and produces the appropriate ViewModelSpec.argN variant.
DevTools Extension
The package includes a Flutter DevTools extension for real-time ViewModel
inspection. In debug mode, a DevToolTracker lifecycle observer is
automatically registered, and a DevToolsService starts a VM service extension
for communication with DevTools.
The graph lists every observed binding explicitly, including initialized root
bindings with no ViewModel edge. Root, dependency, and low-level fallback
bindings have distinct metadata and active/disposed state. A parent
generation's internal scope is rendered as
parent VM → virtual binding → child VM. The graph protocol uses typed
bindingOwnsViewModel and viewModelOwnsDependencyBinding relationships
instead of the legacy untyped edge format. Diagnostics also expose
ordered active owners plus compatibility primaryOwner and handoff metadata
for inbound ownership.
To enable, create devtools_options.yaml in your project root:
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
view_model vs riverpod
Both are built on a central registry + dependency injection model, but they differ in API style, instance scope defaults, and lifecycle ergonomics. This comparison assumes common defaults (for example, a single root ProviderScope) and focuses on core state-management concerns: state modeling, reactive derivation, instance scope, and lifecycle. It does not treat Mutations / Automatic retry / Offline persistence as primary evaluation criteria.
1. Core Philosophy
- Riverpod: Everything is a global reactive node (Functional & Declarative).
Its core is building a global directed acyclic graph (DAG). State is a global singleton by default (mounted on
ProviderScope), and it emphasizes pure functional derivation between states (Derived State). It strongly discourages binding state to a specific Widget instance. - view_model: A classic component-level ViewModel (OOP & Lifecycle-bound).
Its core is reference-counting-based instance management. It injects capabilities into any class via mixins. By default, state is locally scoped (it lives and dies with the bound Widget lifecycle). It is closer to Android's ViewModel or traditional client-side MVVM.
2. Coding Style and Implementation
| Dimension | Riverpod 3.x | view_model 1.0.0 |
|---|---|---|
| Class model | Inheritance/codegen-based (Notifier, AsyncNotifier, @riverpod) |
Mixin-based (class X with ViewModel) |
| Strengths | Strong provider composition and reactive derivation patterns | Low-intrusion style, multi-mixin flexibility, any Dart class can become a ViewModel |
| watch/read location | In Consumer widgets, ref.watch(...) is commonly used in build; it is also used inside provider/notifier build. For listeners outside build in widgets, WidgetRef.listenManual(...) is available |
Can be exposed through a getter (MyViewModel get vm => viewModelBinding.watch(...)), not forced into build |
view_model example (getter declaration):
class _MyPageState extends State<MyPage> with ViewModelStateMixin {
CounterViewModel get counterVM => viewModelBinding.watch(counterSpec);
UserViewModel get userVM => viewModelBinding.watch(userSpec);
@override
Widget build(BuildContext context) {
return Text('${counterVM.count}'); // reactive updates
}
}
3. Instance Scope (Most Important Difference)
- Riverpod: instances are scoped by
ProviderContainer. In most apps, a single rootProviderScopemeans one shared provider instance app-wide. Isolation is explicit via nestedProviderScope, overrides, or families. - view_model: the default is one instance per resolved generic ViewModel type
Tper binding. Repeated same-Twatch/readcalls inside oneViewModelBindingreuse that instance; different pages/bindings are isolated. Use explicit keys for cross-binding sharing or multiple same-Tinstances inside one binding:
final globalAuthSpec = ViewModelSpec<AuthViewModel>(
builder: () => AuthViewModel(),
key: 'global-auth',
aliveForever: true, // optional: retain after the last owner releases it
);
Libraries
- view_model
- A comprehensive ViewModel framework for Flutter applications.