- Flutter
- Dart
- Provider
- Bloc
- MVVM
- State Management
Provider vs Bloc in Flutter: When I Use Each in an MVVM App
Use Provider with a ChangeNotifier view model when a screen's state is simple and local. Use Bloc when a feature has many events, async steps and states that must stay predictable and testable. In an MVVM Flutter app you can, and often should, use both.
The example app
Throughout this post the example is an EdTech-style Flutter app: course modules, video playback and quizzes, backed by Firebase and REST APIs, structured with MVVM and using both Provider and Bloc. The question is never "which one wins", it is "which one fits this feature".
MVVM in 30 seconds
| Layer | Flutter piece | Job |
|---|---|---|
| Model | Repositories, data classes | Talk to Firebase or the REST API |
| ViewModel | ChangeNotifier, Cubit or Bloc | Hold state, run logic |
| View | Widgets | Render state, send user actions |
Widgets never call the API directly. They talk to a view model, and the view model talks to a repository.
Provider: the simple view model
A quiz screen is a good fit. The state is local, the actions are obvious, and nothing outside the screen cares:
class QuizViewModel extends ChangeNotifier {
QuizViewModel(this.questions);
final List<Question> questions;
int _index = 0;
int _score = 0;
bool get isFinished => _index == questions.length;
Question get current => questions[_index]; // read only while !isFinished
int get score => _score;
void answer(int option) {
if (isFinished) return;
if (option == current.correctOption) _score++;
_index++;
notifyListeners();
}
}
ChangeNotifierProvider(
create: (_) => QuizViewModel(questions),
child: const QuizScreen(),
);
// Inside QuizScreen.build
final vm = context.watch<QuizViewModel>();
if (vm.isFinished) return ResultView(score: vm.score);
return QuestionView(question: vm.current, onAnswer: vm.answer);
Why Provider here: one class, no boilerplate, easy to read. Adding Bloc would mean events and states for what is really three fields and one method.
Bloc: the predictable async feature
Loading a course is different. It can be loading, loaded, empty or failed, it can be retried, and other parts of the app react to it. That is where Bloc's explicit events and states earn their extra lines:
sealed class CourseEvent {}
final class CourseRequested extends CourseEvent {
CourseRequested(this.courseId);
final String courseId;
}
sealed class CourseState {}
final class CourseLoading extends CourseState {}
final class CourseLoaded extends CourseState {
CourseLoaded(this.course);
final Course course;
}
final class CourseFailed extends CourseState {
CourseFailed(this.message);
final String message;
}
class CourseBloc extends Bloc<CourseEvent, CourseState> {
CourseBloc(this._repo) : super(CourseLoading()) {
on<CourseRequested>((event, emit) async {
emit(CourseLoading());
try {
emit(CourseLoaded(await _repo.fetchCourse(event.courseId)));
} catch (_) {
emit(CourseFailed('Could not load this course. Try again.'));
}
});
}
final CourseRepository _repo;
}
With Dart 3 sealed classes, the view can switch over every state and the compiler complains if one is missing:
BlocBuilder<CourseBloc, CourseState>(
builder: (context, state) => switch (state) {
CourseLoading() => const Center(child: CircularProgressIndicator()),
CourseLoaded(:final course) => CourseView(course: course),
CourseFailed(:final message) => RetryView(
message: message,
onRetry: () => context.read<CourseBloc>().add(CourseRequested(courseId)),
),
},
);
Why Bloc here: every transition is an event you can log and test, and no screen can end up in a half-loaded state nobody planned for.
How I decide
| Signal | Provider (ChangeNotifier) | Bloc / Cubit |
|---|---|---|
| State lives on one screen | ✅ | |
| A few fields, synchronous updates | ✅ | |
| Several async steps (load, page, retry) | ✅ | |
| Many user events that change state | ✅ | |
| Other features react to this state | ✅ | |
| You need to unit-test every transition | Possible | ✅ Easiest |
A middle option worth knowing: Cubit (from the bloc package) keeps Bloc's explicit states but replaces events with plain methods. It is a good step up when a ChangeNotifier starts to feel messy.
Mistakes to avoid
- Calling the API from a widget. Put it in a repository so both Provider and Bloc view models can reuse it.
- One giant app-wide ChangeNotifier. Every
notifyListeners()rebuilds everything listening. Keep view models per feature. - Bloc for everything. A text field toggle does not need three classes.
- Ignoring
context.readvscontext.watch. Usewatchinbuildto rebuild,readin callbacks to call methods.
Key takeaways
- MVVM first: widgets talk to view models, view models talk to repositories.
- Provider for simple, local screen state.
- Bloc (or Cubit) for async, event-heavy features that must be predictable.
- Mixing both in one app is normal, not a compromise.
Building a Flutter app for iOS and Android? I take this on as a Flutter app developer.
Frequently asked questions
Should I use Provider or Bloc in Flutter?
Use Provider with ChangeNotifier view models for screens with simple, local state such as forms, toggles and quizzes. Use Bloc when a feature has many events, async flows or states that must be predictable and testable, such as loading, paging and retrying data from an API.
Can you use Provider and Bloc in the same Flutter app?
Yes. Provider handles dependency injection and simple view models, while Bloc handles complex features. flutter_bloc itself is built on top of Provider, so the two fit together naturally.
What is MVVM in Flutter?
MVVM separates the Model (data and repositories), the View (widgets) and the ViewModel (state and logic the view binds to). In Flutter the ViewModel is usually a ChangeNotifier exposed with Provider, or a Bloc or Cubit.
Is Bloc overkill for small Flutter apps?
Often, yes. For a screen with a few fields and one API call, a ChangeNotifier view model with Provider is less code and just as clear. Reach for Bloc when the number of events and states starts to grow.