fluttermvvmflutter-change-notifier

Why is Flutter ViewModel initialization inconsistent about awaiting futures only when using Provider?


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?


Solution

  • 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:

    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.

    So:

    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.