The missing ViewModel in Flutter — Everything is ViewModel.
ChangeLog | English Doc | 中文文档
Special thanks to Miolin for transferring the
view_modelpackage ownership.
Coming from an Android background, I wanted Flutter's state and dependency management to be simple and minimally intrusive. After extensive use of Riverpod, I identified several friction points:
- No built-in sharing mechanism: Reusing the same ViewModel instance across pages requires manually threading arguments everywhere
- Intrusive APIs: Constantly inheriting
ConsumerWidgetor wrapping components inConsumerbreaks the natural Flutter widget pattern - Complex dependency graphs: Provider chains create hard-to-trace timing issues and topology complexity that grows with project size
- Feature bloat: Many built-in features (multiple provider types,
AsyncValue, mutations, persistence, retries) add unnecessary complexity when all I need is automatic ViewModel lifecycle management
This library solves these problems with a pragmatic, Android-inspired approach.
- Philosophy: Everything is ViewModel
- Quick Start
- Instance Sharing
- Basic Usage
- ViewModel Lifecycle
- Initialization & Configuration
- Stateful ViewModels
- ViewModel Dependencies
- Advanced Features
- Testing
We redefine "ViewModel" not as a traditional MVVM component, but as a lifecycle-aware manager container that can host any business logic.
In Flutter, everything revolves around widgets. No matter how complex your logic, the ultimate consumer is always a widget. By tying manager lifecycles directly to the widget tree, we achieve the most natural and maintainable architecture.
Forget distinguishing between "Services," "Controllers," or "Stores." It's all just ViewModels. The only difference is where you attach them:
- Global: Attach to your app's root → Singleton for the entire app lifetime
- Local: Attach to a page → Automatically follows page lifecycle
- Shared: Use a unique
key(e.g.,user:$id) → Same instance across multiple widgets
ViewModels can depend on other ViewModels (e.g., UserViewModel reading from NetworkViewModel) while remaining widget-agnostic — they never hold a BuildContext or know about the UI layer.
Compared to GetIt (manual registration) or Riverpod (complex provider graphs), this library is strictly pragmatic: automatic lifecycle management and dependency injection with minimal code.
Through custom Vef (ViewModel Execution Framework), ViewModels can exist independently of widgets:
- Background services: Run logic in isolates or background tasks
- Pure Dart tests: Test ViewModel interactions without
testWidgets - Startup tasks: Execute initialization before any widget renders
See Custom Vef for details.
// 1. Define a ViewModel
class CounterViewModel extends ViewModel {
int count = 0;
void increment() => update(() => count++);
}
// 2. Create a Provider
final counterProvider = ViewModelProvider<CounterViewModel>(
builder: () => CounterViewModel(),
);
// 3. Use in a Widget
class CounterPage extends StatelessWidget with ViewModelStatelessMixin {
@override
Widget build(BuildContext context) {
final vm = vef.watch(counterProvider);
return ElevatedButton(
onPressed: vm.increment,
child: Text('Count: ${vm.count}'),
);
}
}Key APIs:
vef.watch(provider)— Watch ViewModel and rebuild on changesvef.read(provider)— Read ViewModel without triggering rebuildsvef.watchCached<T>(key: ...)— Access existing instance by key (advanced)vef.readCached<T>(key: ...)— Read existing instance by key (advanced)vef.listen(provider, onChanged: ...)— Side-effects without UI updates (auto-disposed)
There are two ways to share ViewModel instances across widgets:
The safest way - the provider ensures the instance exists:
// Define provider with a unique key
final userProvider = ViewModelProvider<UserViewModel>(
builder: () => UserViewModel(userId: id),
key: 'user:$id',
);
// Widget A - Creates or reuses the instance
class WidgetA extends StatefulWidget {
@override
State createState() => _WidgetAState();
}
class _WidgetAState extends State<WidgetA> with ViewModelStateMixin {
UserViewModel get vm => vef.watch(userProvider);
@override
Widget build(BuildContext context) => Text(vm.userName);
}
// Widget B - Reuses the same instance (same key in provider)
class WidgetB extends StatefulWidget {
@override
State createState() => _WidgetBState();
}
class _WidgetBState extends State<WidgetB> with ViewModelStateMixin {
UserViewModel get vm => vef.watch(userProvider); // Same instance
@override
Widget build(BuildContext context) => Text(vm.userEmail);
}When you only know the key but don't have access to the provider (e.g., in deeply nested widgets or across module boundaries):
// Somewhere else created the instance with key 'user:$id'
final userProvider = ViewModelProvider<UserViewModel>(
builder: () => UserViewModel(userId: id),
key: 'user:123',
);
// In another widget/module, access by key directly
class DeepNestedWidget extends StatefulWidget {
@override
State createState() => _DeepNestedWidgetState();
}
class _DeepNestedWidgetState extends State<DeepNestedWidget>
with ViewModelStateMixin {
UserViewModel get vm => vef.watchCached<UserViewModel>(key: 'user:123');
@override
Widget build(BuildContext context) => Text(vm.userName);
}Important:
watchCachedwill throw an error if no instance with the given key exists- Use this method only when you're certain the instance has been created elsewhere
- Prefer Method 1 when possible for better type safety and error prevention
Use cases for direct key lookup:
- Cross-module communication where importing the provider creates circular dependencies
- Plugin architectures where modules don't know about each other's providers
- Dynamic scenarios where keys are generated at runtime
Important: When using custom objects as keys, implement
==andhashCode. Libraries like equatable or freezed can help.Caution: Dart collections (
List,Set,Map) use identity equality by default. Two lists with identical content are considered different keys. Convert to strings or use deep-equality wrappers.
dependencies:
flutter:
sdk: flutter
view_model: # Use latest version
dev_dependencies:
build_runner: ^latest
view_model_generator: ^latestInherit from ViewModel and use update(() => ...) to trigger UI updates:
import 'package:view_model/view_model.dart';
class CounterViewModel extends ViewModel {
int _count = 0;
int get count => _count;
void increment() {
update(() => _count++);
}
@override
void dispose() {
// Clean up resources (streams, timers, etc.)
super.dispose();
}
}Use @genProvider annotation to automatically generate provider boilerplate:
import 'package:view_model/view_model.dart';
import 'package:view_model_annotation/view_model_annotation.dart';
part 'counter_view_model.vm.dart';
@genProvider
class CounterViewModel extends ViewModel {
int count = 0;
void increment() => update(() => count++);
}Run code generation:
dart run build_runner buildWith constructor arguments:
@genProvider
class UserViewModel extends ViewModel {
final String userId;
UserViewModel(this.userId);
}
// Generated provider
final userProvider = ViewModelProvider.arg<UserViewModel, String>(
builder: (userId) => UserViewModel(userId),
);With cache keys:
@GenProvider(key: r'user-$id', tag: r'user-$id')
class UserViewModel extends ViewModel {
final String id;
UserViewModel(this.id);
}
// Automatically generates key/tag closuresSee generator docs for more advanced patterns.
class MyPage extends StatefulWidget {
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> with ViewModelStateMixin<MyPage> {
CounterViewModel get vm => vef.watch(counterProvider);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Text('Count: ${vm.count}'),
floatingActionButton: FloatingActionButton(
onPressed: vm.increment,
child: Icon(Icons.add),
),
);
}
}Warning:
ViewModelStatelessMixinintercepts Element lifecycle and can conflict with other mixins. UseStatefulWidgetpattern when possible.
class CounterWidget extends StatelessWidget with ViewModelStatelessMixin {
CounterViewModel get vm => vef.watch(counterProvider);
@override
Widget build(BuildContext context) {
return Text('Count: ${vm.count}');
}
}No mixin required:
ViewModelBuilder<CounterViewModel>(
provider: counterProvider,
builder: (vm) {
return Text('Count: ${vm.count}');
},
)Bind to existing cached instance:
CachedViewModelBuilder<CounterViewModel>(
shareKey: 'counter-key', // or tag: 'counter-tag'
builder: (vm) => Text('${vm.count}'),
)React to changes without rebuilding the widget. Using vef.listen automatically manages disposal:
@override
void initState() {
super.initState();
// vef.listen auto-disposes when the widget is disposed
vef.listen(counterProvider, onChanged: (vm) {
print('Counter changed: ${vm.count}');
// Show snackbar, navigate, etc.
});
}Lifecycle is managed automatically via reference counting:
- First bind: Widget calls
vef.watch(provider)→ Reference count = 1 - Additional binds: Another widget shares via key → Reference count = 2
- Unbind: First widget disposes → Reference count = 1 (ViewModel stays alive)
- Final disposal: Last widget disposes → Reference count = 0 →
dispose()called
graph LR
A[WidgetA watches] --> B[Ref: 1]
B --> C[WidgetB watches same key]
C --> D[Ref: 2]
D --> E[WidgetA disposed]
E --> F[Ref: 1]
F --> G[WidgetB disposed]
G --> H[Ref: 0 → dispose]
Important: Both
vef.watch()andvef.read()increment the reference count. Usevef.read()when you don't need automatic rebuilds.
Configure global settings in your main() function:
void main() {
ViewModel.initialize(
config: ViewModelConfig(
// Enable debug logging
isLoggingEnabled: true,
// Custom state equality check
equals: (prev, curr) => identical(prev, curr),
),
// Global lifecycle observers
lifecycles: [
MyLifecycleObserver(),
],
);
runApp(MyApp());
}Lifecycle observer example:
class MyLifecycleObserver extends ViewModelLifecycle {
@override
void onCreate(ViewModel vm, InstanceArg arg) {
print('ViewModel created: ${vm.runtimeType}');
}
@override
void onDispose(ViewModel vm, InstanceArg arg) {
print('ViewModel disposed: ${vm.runtimeType}');
}
}For immutable state patterns, use StateViewModel:
@immutable
class CounterState {
final int count;
final String message;
const CounterState({this.count = 0, this.message = 'Ready'});
CounterState copyWith({int? count, String? message}) {
return CounterState(
count: count ?? this.count,
message: message ?? this.message,
);
}
}class CounterViewModel extends StateViewModel<CounterState> {
CounterViewModel() : super(state: CounterState());
void increment() {
setState(state.copyWith(
count: state.count + 1,
message: 'Incremented',
));
}
}class CounterPage extends StatefulWidget {
@override
State createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage>
with ViewModelStateMixin<CounterPage> {
CounterViewModel get vm => vef.watch(counterProvider);
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: ${vm.state.count}'),
Text('Status: ${vm.state.message}'),
ElevatedButton(
onPressed: vm.increment,
child: Text('Increment'),
),
],
);
}
}Using vef.listenState automatically manages disposal:
// Listen to entire state (auto-disposed)
vef.listenState(counterProvider, (prev, curr) {
if (prev.count != curr.count) {
print('Count changed: ${prev.count} → ${curr.count}');
}
});
// Listen to specific field (auto-disposed)
vef.listenStateSelect(
counterProvider,
(state) => state.message,
(prev, curr) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(curr)),
);
},
);Rebuild only when specific state fields change:
StateViewModelValueWatcher<CounterState>(
viewModel: vm,
selectors: [
(state) => state.count,
(state) => state.message,
],
builder: (state) {
return Text('${state.count}: ${state.message}');
},
)Note: By default,
StateViewModelusesidentical()for state comparison (reference equality). This meanssetState()only triggers a rebuild when you provide a new state instance. Customize this behavior viaViewModel.initialize(config: ViewModelConfig(equals: ...))if you need deep equality checks.
ViewModels can depend on other ViewModels:
class NetworkViewModel extends ViewModel {
Future<Response> fetch(String url) async { /* ... */ }
}
class UserViewModel extends ViewModel {
late final NetworkViewModel network;
UserViewModel() {
network = vef.read<NetworkViewModel>(networkProvider);
}
Future<void> loadUser() async {
final response = await network.fetch('/user');
// ...
}
}Key points:
- Dependencies are flat, not nested
- All ViewModels are managed by the widget's state
- Calling
vef.read()inside a ViewModel is identical to calling it from the widget
graph TD
Widget --> ViewModelA
Widget --> ViewModelB
ViewModelA -.depends on.-> ViewModelB
ViewModels automatically pause when widgets are hidden (e.g., navigated away) and resume when visible again. See docs.
Three approaches for optimized rebuilds:
- ValueNotifier + ValueListenableBuilder:
final title = ValueNotifier('Hello');
ValueListenableBuilder(
valueListenable: title,
builder: (_, v, __) => Text(v),
)- ObservableValue + ObserverBuilder (docs):
final observable = ObservableValue<int>(0, shareKey: 'counter');
ObserverBuilder<int>(
observable: observable,
builder: (v) => Text('$v'),
)- StateViewModelValueWatcher (see Fine-Grained Rebuilds)
Use ViewModels outside widgets:
class StartTaskVef with Vef {
AppInitViewModel get initVM => vef.watch(initProvider);
Future<void> run() async {
await initVM.runStartupTasks();
}
@override
void onUpdate() {
print('Init status: ${initVM.status}');
}
}
// In main()
final starter = StartTaskVef();
await starter.run();
starter.dispose();Mock ViewModels using setProxy:
// Define real ViewModel
class AuthViewModel extends ViewModel {
bool get isLoggedIn => true;
}
final authProvider = ViewModelProvider<AuthViewModel>(
builder: () => AuthViewModel(),
);
// Define mock
class MockAuthViewModel extends AuthViewModel {
@override
bool get isLoggedIn => false;
}
// In test
void main() {
testWidgets('Login page', (tester) async {
// Override provider
authProvider.setProxy(
ViewModelProvider(builder: () => MockAuthViewModel()),
);
await tester.pumpWidget(MyApp());
// Verify UI with mocked state
expect(find.text('Please log in'), findsOneWidget);
// Cleanup
authProvider.clearProxy();
});
}Works with argument-based providers too:
final userProvider = ViewModelProvider.arg<UserViewModel, String>(
builder: (id) => UserViewModel(id),
);
// In test
userProvider.setProxy(
ViewModelProvider.arg<UserViewModel, String>(
builder: (id) => MockUserViewModel(id),
),
);MIT License - see LICENSE file for details.
