ViewModel in Flutter
The term ViewModel is rather vague when applied to Flutter. Although it’s frequently mentioned in documentation and technical interviews, there’s no actual ViewModel class or a class that can clearly be identified as one. Typically, state management frameworks try to play that role — but it feels forced or artificial.
During my recent work on a few Flutter projects, I feel like I’ve arrived at an extremely lightweight but powerful code snippet that generally offers the same capabilities I was used to in Android Compose UI projects.
So, without further ado, here we go.
View model as abstraction layer between UI and business logic
class Feature extends ViewModelWidget<FeatureViewModel> {
const Feature ({super.key, factory = FeatureViewModel.factory});
: super(factory: factory);
@override
Widget build(BuildContext context, viewModel) {...}
This is how the view model assumed to be instantiated and used.
ViewModelWidget
extends StatefulWidget
in order to get access to the
life cycle of the widgets and this is a real core of the whole approach.
The build
method passing the viewModel
as a parameter is a convenient way to
get the reference to the created view model instance.
The factory
argument of the ViewModelWidget
constructor is, of course, intended to be called on
demand to create the instance. Having learned a bitter lesson from Android’s ViewModel experience,
I intentionally avoided trying to deduce the factory method automatically and instead decided
to make it explicit.
The expected use of the view model pattern here is we can gather the
methods and properties that are needed by a widget in the viewModel
class and access then as easy
as
viewModel.someProperty
and
viewModel.someMethod()
And here we get the first benefit: abstraction of business logic from the UI code. The ViewModel’s API is the only thing a UI developer needs to work with. Moreover, we can modify the business logic without affecting the UI code — as long as the API stays the same.
Defining a Feature ViewModel class
Defining a ViewModel is as simple as defining any class, with the addition of a single static
factory method(which is just a convenience, not a requirement). This factory is to be passed
to the ViewModelWidget
constructor and called to create the instance of the ViewModel.
class FeatureViewModel {
// some properties and methods
// ...
// The context passed to the factory method
// can be used to access the dependencies
// and create the instance of the other resources
static FeatureViewModel factory(BuildContext context) {
// Create the dependencies and resources
// ...
return FeatureViewModel(context, /*dependencies, resources...*/);
}
}
Now the time to get more use of the suggested approach
Init, Dispose
As you should remember the ViewModelWidget
inherited from StatefulWidget
and got access to the
life cycle methods. We can signal our view model is interested in the life cycle events as well by
adding the interfaces ViewModelWithInit
, ViewModelWithDispose
and ViewModelWithProviders
in
any combination.
class FeatureViewModel implements ViewModelWithInit, ViewModelWithDispose {
@override
void init(BuildContext context) {...}
@override
void dispose() { ... };
}
These Init
and Dispose
are trivial methods what do not need any explanation.
With them, it becomes possible to subscript to the streams, websockets, queues, etc. on demand
and unsubscribe as soon as the widget goes out of the scope.
But there’s the 3rd providers
getter which is a bit more interesting.
Provide
class FeatureViewModel implements ViewModelWithProviders {
@override
List<SingleChildWidget> get providers => [...];
}
First, I assume the use of the provider
package, as it’s the only
DI/state management solution I find truly worth using in Flutter. If someone prefers a different frameworks,
they can easily modify ViewModelWidget
by redefining and implementing their own providers
getter
— it’s not a big deal.
Second it is nothing but just a wrapper around the MultiProvider.providers
and its purpose is to
add the services to the context down to the hierarchy of the widgets started from the ViewModelWidget
instance.
Here the 3rd use: locally scoped services - no app lifetime singletons anymore.
Special case: streamed data
I am used to working with streams and reactive programming, actually “every value is a stream” but
they require careful subscription and un-subscription. Fortunately, the provider
package
handles this for us and all we need to do is to provide the stream to the context.
class FeatureViewModel implements ViewModelWithDispose {
// Put the stream into the build context within the scope of the
// widgets that use it and leverage `provider`'s power
// to manage the subscription and updates
@override
List<SingleChildWidget> get providers => [
StreamProvider<FeatureRelatedType>(
create: (_) => _featureProvider.stream,
initialData: _featureProvider.currentValue),
/// ...
];
}
class FeatureOrItsChild extends StatelssWidget {
@override
Widget build(BuildContext context) {
final value = Provider.of<FeatureRelatedType>(context);
...
}
}
Pay attention the widget does not refer to the view models at all - it just expect the needed data in the context in the same way as if they were put into DI container any other framework. The view model plays a role of a “scoped DI container”.
Access the view model instance
In the beginning I already mentioned the ViewModelWidger.build
method that passes the
viewModel
instance as its argument and nothing prevents anybody just to inherit their
widgets from ViewModelWidget
and use its build
method as many as needed. Due to reference
counting the same instance will be passed and extra cost will be paid only for the StatefullWidget
creation.
Meanwhile, it seemed too expensive and unnatural to me, so I decided to add the instance
of FeatureViewModel
to the build context. This way, it can be accessed as
easily as Provider.of<FeatureViewModel>(context)
in any widget down the hierarchy,
starting from the ViewModelWidget
instance.
and there’s syntax sugar for that:
extension ViewModelExtension on BuildContext {
T viewModel<T>() {
return Provider.of<T>(this, listen: false);
}
}
That closely resembles the viewModel
method from Android Compose UI and makes retrieving the
view model instance even a little bit cleaner:
final vm = context.viewModel<FeatureViewModel>();
… and even more beautiful:
extension FeatureViewModelExtension on BuildContext {
FeaturenViewModel get authorizationViewModel =>
viewModel<AuthorizationViewModel>();
}
final vm = context.authorizationViewModel;
Conclusion
With this view model approach, I can access a bunch of properties and methods needed for a given widget,
create resources with lifetimes scoped to the widget (like TextEditingController
instances, which
are the most common example), and—most importantly for me, as someone addicted to reactive
programming—create streams and provide their data to widgets just as easily as I could with React
hooks or Android Compose UI states.
And that’s all about the approach itself, the further description is about implementation details not everyone might be interested in.
Tech internals
Reference counting
Like Android Compose UI the suggested implementation of ViewModelWidget
tracks the use of
the view models of the same class and creates the new instances only one reusing them on the
sequential factory method calls.
The suggested implementation
Due to its simplicity, I prefer to keep the entire source code snippet in a single file that I can copy from one project to another, rather than creating a full library or package. Since the idea is still fresh (at least to me, as a Flutter newcomer), this approach offers flexibility—for me and others—to add tweaks and additional functionality that can make it even more useful.
library;
import 'package:flutter/widgets.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:provider/single_child_widget.dart';
/// An interface view model might implement
/// if it has to run some code on initialization
abstract interface class ViewModelWithInit {
void init(BuildContext context) {}
}
/// An interface view model might implement
/// if it has to run some code on dispose
abstract interface class ViewModelWithDispose {
void dispose() {}
}
/// An interface view model might implement
/// if it has to run some code on dispose
abstract interface class ViewModelWithProviders {
List<SingleChildWidget> get providers;
}
abstract class ViewModelWidget<VM> extends StatefulWidget {
final VM Function(BuildContext) _factory;
const ViewModelWidget({super.key, required factory}) : _factory = factory;
Widget build(BuildContext context, VM viewModel);
@override
State<ViewModelWidget<VM>> createState() => _ViewModelState<VM>();
}
class _ViewModelState<VM> extends State<ViewModelWidget<VM>> {
late final VM vm;
@override
void didChangeDependencies() {
super.didChangeDependencies();
vm = _registry.get<VM>(context, widget._factory);
}
@override
void dispose() {
if (vm is ViewModelWithDispose) {
(vm as ViewModelWithDispose).dispose();
}
_registry.release<VM>();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (Provider.of<VM?>(context, listen: false) == null) {
if (vm is ViewModelWithProviders) {
final List<SingleChildWidget> providers = [];
providers.addAll((vm as ViewModelWithProviders).providers);
providers.add(Provider(create: (_) => vm));
return MultiProvider(
providers: providers,
child: Builder(
builder: (context) {
return widget.build(context, vm);
},
),
);
} else {
return Provider<VM>(
create: (_) => vm,
child: Builder(
builder: (context) {
return widget.build(context, vm);
},
),
);
}
}
return Builder(
builder: (context) {
return widget.build(context, vm);
},
);
}
static final _ModelViewRegistry _registry = _ModelViewRegistry();
}
class _ModelViewRegistry {
final Map<Type, _ModelViewReference> _instances = {};
VM get<VM>(BuildContext context, VM Function(BuildContext) factory) {
final existing = _instances[VM];
if (existing == null) {
final vm = factory(context);
if (vm is ViewModelWithInit) {
(vm as ViewModelWithInit).init(context);
}
// NOTE: it won't be added if init has thrown
final ref = _ModelViewReference(vm, context);
log.finest("instantiated $ref");
_instances[VM] = ref;
return vm;
}
log.finest("reuse $existing");
if (existing.counter == 0) {
log.severe(
"The $VM instance is referenced 0 times, this should never happen during referencing");
}
if (!isDescendant(existing.context, context)) {
// TODO: it is assumed all the models of the same class are
// created within the same hierarchy, but it can be extended
// if the map key contains both ViewModel class and context
log.severe(
'The ViewModel\'s context is not a descendant of the context it was instantiated first.');
}
final vm = existing.vm as VM;
existing.counter += 1;
return vm;
}
void release<VM>() {
final existing = _instances[VM];
if (existing == null) {
log.severe("There's no previously initialized view model $VM");
return;
}
log.finest("release $existing");
if (existing.counter <= 0) {
log.warning(
"The $VM instance is referenced ${existing.counter} times, this should never happen during release");
// try to remove it anyway
existing.counter = 1;
}
existing.counter -= 1;
if (existing.counter == 0) {
_instances.remove(VM);
if (existing is ViewModelWithDispose) {
(existing as ViewModelWithDispose).dispose();
}
}
}
static bool isDescendant(BuildContext descendant, BuildContext ancestor) {
BuildContext? current = descendant;
while (current != null) {
if (current == ancestor) {
return true;
}
current = current.findAncestorStateOfType<State>()?.context;
}
return false;
}
}
class _ModelViewReference<VM> {
final VM vm;
final BuildContext context;
int counter = 1;
_ModelViewReference(this.vm, this.context);
@override
String toString() {
// TODO: implement toString
return "$VM (reference counter=$counter)";
}
}
final log = Logger("ViewModel");
extension ViewModelExtension on BuildContext {
T viewModel<T>() {
return Provider.of<T>(this, listen: false);
}
}