I'm learning about Flutter, and I have questions about implementing an MVVM architecture. I'm uncertain how to handle the initialization logic for the ViewModel, particularly when using Provider for dependency injection. I'm getting some inconsistent behavior and I would like to understand why.
I have seen plenty of MVVM implementations for Flutter and Dart, so I want to clarify I'm following the one described in the documentation (about state management and architecture) which makes the ViewModel a ChangeNotifier, and using Provider for dependency injection.
After further experimentation, I can say the issues I talk about happen only when using Provider. No error or exception is thrown if I pass the ViewModel manually.
I will borrow the ViewModel example code (as well as a major part for the view of the same example) described in the documentation to display the inconsistencies.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(ChangeNotifierProvider(create: (context) => CounterViewModel(CounterModel()), child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
home: Scaffold(body: CounterView(viewModel: context.read()))
);
}
}
class CounterView extends StatelessWidget {
CounterView({super.key, required this.viewModel}) {
viewModel.init();
}
final CounterViewModel viewModel;
@override
Widget build(BuildContext context) {
return ListenableBuilder( // This could have been a context.watch()
listenable: viewModel,
builder: (context, child) {
return Column(
children: [
if (viewModel.errorMessage != null)
Text(
'Error: ${viewModel.errorMessage}',
style: Theme
.of(context)
.textTheme
.labelSmall
?.apply(color: Colors.red),
),
Text('Count: ${viewModel.count}'),
TextButton(
onPressed: () {
viewModel.increment();
},
child: Text('Increment'),
),
],
);
},
);
}
}
class CounterData {
CounterData(this.count);
final int count;
}
class CounterModel {
Future<CounterData> loadCountFromServer() async {
// Some complex operation here
return Future.delayed(Duration(seconds: 5)).then((_) => Future.value(CounterData(4)));
}
Future<CounterData> updateCountOnServer(int newCount) async {
// Another bunch of logic here
return CounterData(4);
}
}
class CounterViewModel extends ChangeNotifier {
final CounterModel model;
int? count;
String? errorMessage;
CounterViewModel(this.model);
Future<void> init() async {
try {
count = (await model.loadCountFromServer()).count;
} catch (e) {
errorMessage = 'Could not initialize counter';
}
notifyListeners();
}
Future<void> increment() async {
// Some other logic that eventually calls and awaits updateCountOnServer
notifyListeners();
}
}
This code above works and doesn't throw any error. It works even if I set the delay for loadCountFromServer to zero, or even remove the delay completely, as long as I await a future in the init before calling notifyListeners.
Now let's suppose I have a loading field in my ViewModel that I don't need to await, and I want to update before and after getting the count from the server. The ViewModel becomes like this.
class CounterViewModel extends ChangeNotifier {
final CounterModel model;
int? count;
String? errorMessage;
bool? loading;
CounterViewModel(this.model);
Future<void> init() async {
try {
loading = true;
notifyListeners();
count = (await model.loadCountFromServer()).count;
loading = false;
} catch (e) {
errorMessage = 'Could not initialize counter';
}
notifyListeners();
}
// ...
}
However, this throws an error: setState() or markNeedsBuild() called during build.
This doesn't happen if I make it into a future and await it.
// ...
Future<void> init() async {
try {
loading = await Future.value(true); // Awaiting this future makes the code work
notifyListeners();
count = (await model.loadCountFromServer()).count;
loading = false;
} catch (e) {
errorMessage = 'Could not initialize counter';
}
notifyListeners();
}
// ...
This also doesn't happen if I don't use Provider.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
home: Scaffold(body: CounterView(viewModel: CounterViewModel(CounterModel()))) // No exception if I make the viewmodels myself
);
}
}
// ...
class CounterViewModel extends ChangeNotifier {
final CounterModel model;
int? count;
String? errorMessage;
bool? loading;
CounterViewModel(this.model);
Future<void> init() async {
try {
loading = true; // This works
notifyListeners();
count = (await model.loadCountFromServer()).count;
loading = false;
} catch (e) {
errorMessage = 'Could not initialize counter';
}
notifyListeners();
}
// ...
}
// ...
This is really inconsistent and confusing, and it doesn't make sense to me.
If notifyListeners was called after the build completes because of the delay it would have made sense, but the behavior is the same if I await futures I don't really wait for.
If this was a language thing that kept the first part synchronous even after marking it async it should have not worked when I passed the ViewModel manually too, but it worked then and not when using Provider.
The same happens with a Stateful Widget that doesn't take the ViewModel as an argument, but it instead uses context.read() in the initState. It throws the same error (setState() or markNeedsBuild() called during build.), unless I await anything.
This excludes the possibility that notifyListeners invalidates the CounterView widget itself as it is constructing because one of its constructor parameters changed.
A workaround to this would be to call the init eagerly in the ViewModel constructor but it isn't suitable for when I need a parameter passed to the screen or to wait some other factor like authentication.
Another better workaround to this would be to use addPostFrameCallback (on either WidgetsBinding or SchedulerBinding) to make sure the notifyListeners doesn't throw the error by postponing the init. However this doesn't solve the problem at its root.
What causes this behavior, why only with Provider and how do I fix it?
You’re calling notifyListeners while the Provider’s own widget is still building. Provider listens to your ChangeNotifier and calls setState on its internal element when it gets notified. If you notify synchronously during the same build pass that created the provider, you hit “setState() or markNeedsBuild() called during build.” When you introduce any await (even a no operation, like await Future.value()), the notification moves to a later microtask/frame and the build can finish, so there isn’t any error. Passing the ViewModel manually doesn’t crash, because there isn’t any Provider element subscribed and attempting to rebuild during that same build.
Why it happens only with Provider:
Provider via ChangeNotifierProvider subscribes to your ChangeNotifier immediately (in the element build). When the notifier fires synchronously (before any await), the provider element tries to setState while it is still building -> exception.
When you manually instantiate the ViewModel and pass it down, there isn’t an ancestor Provider element subscribed and calling setState at that moment. ListenableBuilder only attaches its listener after it’s built, so your early notify has no one to synchronously rebuild yet.
Awaiting anything before the first notify defers the notify to after the current build, avoiding the conflict.
What to do instead (safe patterns):
Pick one that fits your needs. The goal is: do not call notifyListeners synchronously in the same build pass that mounts the provider.
Initialize in Provider.create, but defer the first notify
ChangeNotifierProvider(
create: (_) {
final vm = CounterViewModel(CounterModel());
// Defer init so first notify happens after build
scheduleMicrotask(vm.init); // or WidgetsBinding.instance.addPostFrameCallback((_) => vm.init());
return vm;
},
child: MyApp(),
);
If you must kick off from the view: don’t call init() in the widget constructor. Use initState and defer
class CounterView extends StatefulWidget {
const CounterView({super.key});
@override
State<CounterView> createState() => _CounterViewState();
}
class _CounterViewState extends State<CounterView> {
@override
void initState() {
super.initState();
final vm = context.read<CounterViewModel>();
// Defer so Provider finishes building before notify
WidgetsBinding.instance.addPostFrameCallback((_) => vm.init());
}
@override
Widget build(BuildContext context) {
final vm = context.watch<CounterViewModel>();
// ...
}
}
Make init’s first notify asynchronous
Future<void> init() async {
loading = true;
// Ensure the first notify happens after the current build pass
await Future<void>.delayed(Duration.zero); // or await null;
notifyListeners();
try {
count = (await model.loadCountFromServer()).count;
} catch (_) {
errorMessage = 'Could not initialize counter';
} finally {
loading = false;
notifyListeners();
}
}
Pre-create the ViewModel and use ChangeNotifierProvider.value
final vm = CounterViewModel(CounterModel());
runApp(
ChangeNotifierProvider.value(
value: vm,
child: MyApp(),
),
);
// Kick off later (post-frame or microtask) to avoid sync notify during build
scheduleMicrotask(vm.init);
So:
Avoid calling init() in a StatelessWidget constructor; constructors run during the parent’s build.
context.read() in initState is fine, but you must still defer any notify that could occur immediately.
If you need an immediate “loading = true” visual state, set loading = true synchronously but delay the first notify via microtask/post-frame. That preserves instant state in the model while avoiding a synchronous rebuild during build.
In essence: Provider is doing its job (rebuilding on changes). The crash is because the very first change happens synchronously while Provider’s element is still building. Defer your first notify and you’ll be consistent across cases.