State management is one of those topics that every Flutter developer eventually has to wrestle with. After building multiple production apps and experimenting with every major solution, I want to share an honest, practical comparison of the three most popular approaches in 2026: Provider, Riverpod, and Bloc.
Why State Management Matters
In Flutter, everything is a widget, and widgets rebuild when their state changes. For small apps, calling setState inside a StatefulWidget works fine. But as your app grows, you hit real problems:
- Prop drilling. Passing data through multiple widget layers just to reach the one that needs it.
- Shared state. Multiple widgets need access to the same data, and changes in one should reflect in others.
- Separation of concerns. Business logic gets tangled with UI code, making testing and maintenance painful.
- Predictability. Debugging state changes becomes impossible when state mutations happen everywhere.
Let’s look at how each solution tackles these problems. To keep the comparison fair, I’ll implement the same feature with each approach: a simple todo list with add and toggle functionality.
Provider: The Official Recommendation
Provider was the first community package officially recommended by the Flutter team. It builds on top of InheritedWidget, making it feel natural to Flutter developers.
How It Works
You create a ChangeNotifier class that holds your state and notifies listeners when it changes, then provide it to the widget tree using ChangeNotifierProvider.
class TodoNotifier extends ChangeNotifier {
final List<Todo> _todos = [];
List<Todo> get todos => List.unmodifiable(_todos);
void addTodo(String title) {
_todos.add(Todo(title: title));
notifyListeners();
}
void toggleTodo(int index) {
_todos[index].isDone = !_todos[index].isDone;
notifyListeners();
}
}
Providing it at the top of your widget tree:
ChangeNotifierProvider(
create: (_) => TodoNotifier(),
child: const MyApp(),
)
Consuming it in your widgets:
class TodoListScreen extends StatelessWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context) {
final todoNotifier = context.watch<TodoNotifier>();
return ListView.builder(
itemCount: todoNotifier.todos.length,
itemBuilder: (context, index) {
final todo = todoNotifier.todos[index];
return CheckboxListTile(
title: Text(todo.title),
value: todo.isDone,
onChanged: (_) => todoNotifier.toggleTodo(index),
);
},
);
}
}
Pros
- Low learning curve. If you understand
InheritedWidget, Provider feels intuitive. - Minimal boilerplate. A ChangeNotifier class and a Provider widget — that’s it.
- Official Flutter team endorsement. Extensive documentation and community resources.
- Flexible. Works with
ChangeNotifier,ValueNotifier,Stream, or even plain values.
Cons
- Runtime errors. If you try to read a provider that hasn’t been provided above in the tree, you get a runtime exception — not a compile-time error.
- Context dependency. You always need a
BuildContextto access providers, which makes accessing state from outside the widget tree awkward. - Over-notification.
ChangeNotifiernotifies all listeners on any change. If your state object has 10 properties and you update one, every widget watching that notifier rebuilds. - No built-in testing utilities. Testing requires manually setting up the provider tree.
Riverpod: Provider, Reimagined
Riverpod was created by Remi Rousselet — the same developer who created Provider. It addresses nearly every limitation of Provider while introducing a more powerful and flexible API.
How It Works
Riverpod uses global provider declarations that are compile-time safe and don’t depend on the widget tree:
@riverpod
class TodoNotifier extends _$TodoNotifier {
@override
List<Todo> build() => [];
void addTodo(String title) {
state = [...state, Todo(title: title)];
}
void toggleTodo(int index) {
state = [
for (int i = 0; i < state.length; i++)
if (i == index)
state[i].copyWith(isDone: !state[i].isDone)
else
state[i],
];
}
}
Wrapping your app:
void main() {
runApp(const ProviderScope(child: MyApp()));
}
Consuming in widgets:
class TodoListScreen extends ConsumerWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todoNotifierProvider);
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return CheckboxListTile(
title: Text(todo.title),
value: todo.isDone,
onChanged: (_) =>
ref.read(todoNotifierProvider.notifier).toggleTodo(index),
);
},
);
}
}
Pros
- Compile-time safety. No more runtime “ProviderNotFoundException.” If a provider doesn’t exist, your code won’t compile.
- No BuildContext required. Providers can be accessed anywhere using a
Ref, making testing and non-UI access straightforward. - Fine-grained rebuilds. Using
select, you can watch specific parts of state, minimizing unnecessary rebuilds. - Built-in support for async.
FutureProviderandStreamProviderhandle loading and error states elegantly. - Auto-dispose. Providers can automatically clean up when no longer listened to.
- Code generation. The
riverpod_generatorpackage reduces boilerplate significantly.
Cons
- Steeper learning curve. The concepts of
Ref,ConsumerWidget, and provider modifiers take time to internalize. - Code generation dependency. While not strictly required, the recommended approach relies on
build_runner, which adds complexity to your build process. - Different paradigm. If your team is used to Provider, the migration requires rethinking how state is structured.
- Rapid evolution. The API has changed significantly between major versions, which can make finding up-to-date tutorials harder.
Bloc: Enterprise-Grade Predictability
Bloc (Business Logic Component) takes a fundamentally different approach. Instead of mutable state with notifications, Bloc uses a stream-based, event-driven architecture inspired by Redux concepts.
How It Works
You define events (inputs) and states (outputs), and the Bloc maps events to state transitions:
// Events
sealed class TodoEvent {}
class AddTodo extends TodoEvent {
final String title;
AddTodo(this.title);
}
class ToggleTodo extends TodoEvent {
final int index;
ToggleTodo(this.index);
}
// Bloc
class TodoBloc extends Bloc<TodoEvent, List<Todo>> {
TodoBloc() : super([]) {
on<AddTodo>((event, emit) {
emit([...state, Todo(title: event.title)]);
});
on<ToggleTodo>((event, emit) {
emit([
for (int i = 0; i < state.length; i++)
if (i == event.index)
state[i].copyWith(isDone: !state[i].isDone)
else
state[i],
]);
});
}
}
For simpler cases, Cubit offers a lighter alternative without events:
class TodoCubit extends Cubit<List<Todo>> {
TodoCubit() : super([]);
void addTodo(String title) {
emit([...state, Todo(title: title)]);
}
void toggleTodo(int index) {
emit([
for (int i = 0; i < state.length; i++)
if (i == index)
state[i].copyWith(isDone: !state[i].isDone)
else
state[i],
]);
}
}
Consuming in widgets:
class TodoListScreen extends StatelessWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<TodoBloc, List<Todo>>(
builder: (context, todos) {
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return CheckboxListTile(
title: Text(todo.title),
value: todo.isDone,
onChanged: (_) =>
context.read<TodoBloc>().add(ToggleTodo(index)),
);
},
);
},
);
}
}
Pros
- Predictable state transitions. Every state change flows through a defined path: Event → Bloc → State. This makes debugging straightforward.
- Excellent testability. Testing a Bloc is pure logic testing — fire events, assert states. No widget tree needed.
- BlocObserver. A global observer lets you log, track, or react to every state change across your entire app. Invaluable for debugging.
- Scales well. The enforced structure keeps large codebases organized.
- Strong community. Comprehensive documentation, many tutorials, and active maintenance.
Cons
- Boilerplate. The event-state pattern requires more code than Provider or Riverpod for simple features.
- Overkill for simple apps. If you’re building a small app, the Bloc architecture can feel like driving a semi truck to the grocery store.
- Learning curve. Understanding streams, events, states, and the overall pattern takes more initial investment.
- Event overhead. For simple state changes, creating separate event classes feels unnecessary (though Cubit mitigates this).
Comparison Table
| Feature | Provider | Riverpod | Bloc |
|---|---|---|---|
| Learning curve | Low | Medium | Medium-High |
| Boilerplate | Low | Low-Medium | High |
| Compile-time safety | No | Yes | Partial |
| Testing ease | Moderate | Excellent | Excellent |
| Async support | Manual | Built-in | Built-in |
| Scalability | Moderate | High | High |
| Performance control | Limited | Fine-grained | Good |
| DevTools support | Basic | Good | Excellent |
| Code generation needed | No | Recommended | No |
When to Use Which
Choose Provider when:
- You’re building a small to medium app
- Your team is new to Flutter and needs something simple
- You want minimal dependencies and setup
Choose Riverpod when:
- You want compile-time safety and better testing
- Your app has complex dependency chains between different state objects
- You need fine-grained control over rebuilds
- You’re starting a new project and can invest in learning the API
Choose Bloc when:
- You’re working on a large or enterprise application
- Your team values strict architectural patterns
- You need comprehensive state change logging and debugging
- You’re working in a team environment where consistency matters
What I Use in My Own Apps
For my indie apps like Happy Balloon Pop, I use Riverpod. Here’s why:
My apps are small to medium in size, which rules out Bloc’s overhead. But they have enough complexity — managing game state, user preferences, ad loading, and in-app purchases — that plain Provider starts to show its limitations.
Riverpod’s compile-time safety has saved me from bugs multiple times. Its auto-dispose feature is perfect for game screens where state should reset when the user navigates away. And the ability to access providers without BuildContext is essential when I need to interact with state from platform channels or background isolates.
That said, I started with Provider and migrated to Riverpod gradually. If you’re just beginning your Flutter journey, there’s nothing wrong with starting with Provider and growing into Riverpod as your needs evolve.
Migration Tips
If you’re moving from Provider to Riverpod:
- Start with new features. Don’t rewrite existing code immediately. Use Riverpod for new screens and features.
- Both can coexist. Provider and Riverpod can live in the same app during migration.
- Convert ChangeNotifiers first. Your existing
ChangeNotifierclasses can work with Riverpod’sChangeNotifierProvideras a stepping stone. - Adopt code generation gradually. You can use Riverpod without code generation initially, then add it as you get comfortable.
If you’re moving from Provider to Bloc:
- Start with Cubit. It’s conceptually closer to Provider’s
ChangeNotifierand requires less restructuring. - Extract business logic. Move logic out of widgets into Cubit/Bloc classes one screen at a time.
- Add events later. Once you’re comfortable with Cubit, convert high-complexity features to full Bloc with events for better traceability.
The best state management solution is the one your team understands and can maintain. Don’t chase trends — pick the approach that fits your project’s complexity, your team’s experience, and your long-term maintenance needs. Start simple, and add complexity only when the problems it solves are problems you actually have.