Flutter ships one built-in reactivity primitive, setState(), and four community answers to its limits. Provider is the minimal option. Riverpod is its compile-safe successor. Bloc enforces event-driven architecture. Signals brings fine-grained reactivity from the JavaScript world. This guide shows the same feature in each, then tells you which to choose.
Quick Takeaways
- Provider is the lowest-ceremony choice. It fits small apps and teams already invested in
ChangeNotifier. - Riverpod 3 is the strongest default for new apps. It has compile-time safety, built-in async handling, and automatic retry.
- Bloc/Cubit gives the strictest structure and the best traceability. It costs the most boilerplate.
- Signals gives the finest-grained rebuilds and the smallest API. Its conventions for structure and testing are the least settled.
| Criteria | Provider | Riverpod 3 | Bloc / Cubit | Signals |
|---|---|---|---|---|
| Learning curve | Low | Medium | Medium-High | Low |
| Boilerplate | Low | Low-Medium | High | Very low |
| Async handling | Manual | Built-in (AsyncValue) |
Manual (states) | FutureSignal / manual |
| Rebuild granularity | Widget (via select) |
Widget (via select) |
Widget (buildWhen) |
Individual reactive expression |
| Compile-time safety | Runtime lookup | Strong | Medium | Strong (global) / Medium |
| Testability | Good | Excellent | Excellent | Good |
| Best for | Small apps | Most new apps | Large, regulated teams | Performance-sensitive UIs |
Where Each Library Stands in 2026
Versions matter because the APIs changed substantially in the last year.
Riverpod’s stable 3.0 release landed on September 10, 2025. The package has kept shipping since, and 3.4.1 was published on July 26, 2026. One migration guide notes that Riverpod 3 includes built-in automatic retry with exponential backoff.
Signals for Dart has also matured. signals_flutter is at version 6.3.0, with a stable API that has changed little since the 6.x line began.
flutter_bloc stays on its 9.x line. Bloc’s API is deliberately stable, so a slow release cadence signals maturity, not abandonment.
Provider remains available and works. It is the same author’s earlier package, and Riverpod’s changelog now includes tooling that eases migration from Provider to Riverpod.
The Shared Problem: What State Management Solves
Every library here answers three questions:
- Where does state live? Inside a widget, above a subtree, or globally.
- How does the UI learn it changed? Listeners, streams, or dependency tracking.
- How do you test it without a widget tree? Plain Dart objects versus framework-bound objects.
Use setState() for ephemeral state that one widget owns, such as a toggle or a text field’s focus. Reach for a library when app state crosses widget boundaries: auth sessions, carts, remote data, and settings.
Provider: Minimal Scoped State
Provider wraps InheritedWidget with a friendlier API. You expose a ChangeNotifier and listen to it below.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners(); // Triggers a rebuild in every dependent widget.
}
}
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: const MaterialApp(home: CounterPage()),
),
);
}
The page reads state with select, so it rebuilds only when the selected value changes.
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
// Rebuilds only when `count` changes, not on every notify.
final count = context.select<CounterModel, int>((m) => m.count);
return Scaffold(
body: Center(child: Text('$count')),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<CounterModel>().increment(),
child: const Icon(Icons.add),
),
);
}
}
The output is a counter that increments on tap. Only the Text subtree depends on count.
Where Provider strains:
- Lookups happen at runtime. Requesting a provider that is not in the tree throws
ProviderNotFoundExceptionwhen the widget builds. - Async state is manual. You hand-write
isLoading,error, anddatafields. - Providers that depend on other providers need
ProxyProvider, which gets verbose fast.
Riverpod 3: Compile-Safe, Async-First
Riverpod removes the widget-tree dependency. Providers are globally declared and resolved through a ProviderContainer, so a missing provider is a compile error, not a runtime crash.
Synchronous State with a Notifier
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'counter.g.dart';
@riverpod
class Counter extends _$Counter {
@override
int build() => 0; // Initial state.
void increment() => state++; // Assigning state notifies listeners.
}
Run dart run build_runner build to generate counter.g.dart. If you skip code generation, the manual equivalent is NotifierProvider<Counter, int>(Counter.new).
Async State Without Boilerplate
@riverpod
Future<List<Todo>> todos(Ref ref) async {
final repo = ref.watch(todoRepositoryProvider);
return repo.fetchAll(); // Loading and error states come for free.
}
class TodoList extends ConsumerWidget {
const TodoList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todosProvider);
return todos.when(
data: (items) => ListView(
children: [for (final t in items) ListTile(title: Text(t.title))],
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('Failed: $e'),
);
}
}
The provider returns an AsyncValue with three states. Failed fetches retry automatically, so you no longer write a retry loop in every repository call.
What Changed in Riverpod 3
| Change | Impact |
|---|---|
| Unified Ref type | No more generated TodosRef types. One Ref everywhere. |
StateProvider and StateNotifierProvider moved to legacy.dart |
Old code needs an import change. New code uses Notifier. |
| Automatic retry | Failed providers retry with exponential backoff. |
autoDispose as a modifier |
No separate base class. |
Exceptions wrapped in ProviderException |
Catch the wrapper, then inspect .exception. |
The reported migration cost for a real app moving from 2.x is small. One guide estimates about half a day, mostly mechanical search-and-replace. Projects already using @riverpod code generation have even less to change.
Where Riverpod strains: The concept count is higher than Provider’s: ref.watch versus ref.read versus ref.listen, plus provider families and scoping. Code generation adds a build step.
Bloc and Cubit: Explicit Events, Predictable Transitions
Bloc separates events (input) from states (output). Every state change is traceable to an event, which makes logs, replay, and audits straightforward.
Cubit: The Lightweight Form
import 'package:flutter_bloc/flutter_bloc.dart';
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
Bloc: Event-Driven with Concurrency Control
A search box is where Bloc earns its boilerplate. The restartable() transformer cancels stale requests.
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
sealed class SearchEvent {}
final class QueryChanged extends SearchEvent {
QueryChanged(this.query);
final String query;
}
sealed class SearchState {}
final class SearchIdle extends SearchState {}
final class SearchLoading extends SearchState {}
final class SearchSuccess extends SearchState {
SearchSuccess(this.results);
final List<String> results;
}
final class SearchFailure extends SearchState {}
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc(this._repo) : super(SearchIdle()) {
on<QueryChanged>(_onQueryChanged, transformer: restartable());
}
final SearchRepository _repo;
Future<void> _onQueryChanged(
QueryChanged event,
Emitter<SearchState> emit,
) async {
emit(SearchLoading());
try {
final results = await _repo.search(event.query);
emit(SearchSuccess(results));
} catch (_) {
emit(SearchFailure());
}
}
}
When a new QueryChanged arrives mid-request, restartable() cancels the previous handler. The UI never shows results for an outdated query. Sealed classes also make the switch over states exhaustive, so the compiler flags any missing case.
Where Bloc strains: Every feature needs an event class, a state class, and a handler. For a settings toggle, that is too much ceremony. Use Cubit for simple cases and reserve full Bloc for flows with real concurrency or audit needs.
Signals: Fine-Grained Reactivity
Signals track dependencies automatically. When a signal changes, only the expressions that read it re-run. There is no listener registration and no provider tree.
import 'package:flutter/material.dart';
import 'package:signals_flutter/signals_flutter.dart';
final counter = signal(0);
final doubled = computed(() => counter.value * 2); // Memoized derived value.
class CounterView extends StatelessWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
// Only this builder re-runs when `counter` changes.
Watch((context) => Text('Count: ${counter.value}')),
Watch((context) => Text('Doubled: ${doubled.value}')),
ElevatedButton(
onPressed: () => counter.value++,
child: const Text('Increment'),
),
],
);
}
}
The two Text widgets rebuild independently. The surrounding Column and the button never rebuild. A computed signal recalculates only when its dependencies change, and caches the result otherwise.
Side Effects
// Runs once immediately, then again whenever `counter` changes.
final dispose = effect(() {
debugPrint('Counter is now ${counter.value}');
});
// Call dispose() when the effect's owner is torn down.
Where Signals strains:
- Global signals are easy to write and easy to couple tightly. Without discipline, your app becomes a web of module-level variables.
- Dependency injection is not built in. You pair signals with
get_itor anInheritedWidgetto scope and swap them in tests. - Community conventions for large codebases are thinner than for Bloc or Riverpod.
Performance and Memory Trade-Offs
Rebuild cost scales with how many widgets a change touches. In big-O terms, a naive notifyListeners() on a model costs O(n) in the number of dependent widgets. Selectors and signals shrink that to the widgets that read the changed field.
| Aspect | Provider | Riverpod 3 | Bloc | Signals |
|---|---|---|---|---|
| Update propagation | Notifies all listeners, filtered by select |
Notifies watchers of changed provider | Emits state, filtered by buildWhen |
Notifies only dependent expressions |
| Derived state | Manual or ProxyProvider |
Providers watching providers | Manual in the bloc | computed (memoized) |
| Disposal | Tied to widget tree | autoDispose modifier |
Closed by BlocProvider |
Manual or via widget mixin |
| Memory overhead | Low | Low-Medium (provider container) | Medium (streams, event queue) | Low |
| DevTools | Basic | Dedicated devtool extension | Bloc Observer, logging | Signals devtool |
None of these libraries is a bottleneck in typical apps. Real slowness comes from rebuilding large subtrees. Selectors, buildWhen, and Watch all fix that, so pick based on architecture fit, not micro-benchmarks.
Bad Code vs. Good Code: Refactoring Scenarios
Anti-Pattern 1: Watching the Whole Model (Provider)
// BAD: rebuilds the entire page on ANY change to CartModel.
class CartBadge extends StatelessWidget {
const CartBadge({super.key});
@override
Widget build(BuildContext context) {
final cart = context.watch<CartModel>();
return Text('${cart.items.length}');
}
}
// GOOD: rebuilds only when the item count changes.
class CartBadge extends StatelessWidget {
const CartBadge({super.key});
@override
Widget build(BuildContext context) {
final count = context.select<CartModel, int>((c) => c.items.length);
return Text('$count');
}
}
Anti-Pattern 2: Reading Inside build (Riverpod)
// BAD: ref.read inside build never subscribes. The UI goes stale.
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.read(userProvider);
return Text(user.name);
}
// GOOD: watch in build, read in callbacks.
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return ElevatedButton(
onPressed: () => ref.read(userProvider.notifier).refresh(),
child: Text(user.name),
);
}
Anti-Pattern 3: Mutating State In Place (Bloc)
// BAD: same list instance, so Bloc sees equal state and skips the emit.
void addItem(Item item) {
state.items.add(item);
emit(state);
}
// GOOD: create a new state object. Use Equatable or freezed for equality.
void addItem(Item item) {
emit(state.copyWith(items: [...state.items, item]));
}
Anti-Pattern 4: Unscoped Global Signals
// BAD: module-level mutable state that every test shares.
final cartItems = signal<List<Item>>([]);
// GOOD: wrap signals in a class so you can create, inject, and dispose them.
class CartController {
final items = signal<List<Item>>([]);
late final count = computed(() => items.value.length);
void add(Item item) => items.value = [...items.value, item];
void dispose() {
count.dispose();
items.dispose();
}
}
Choosing the Right Tool
Use this table as a starting point, then adjust for your team.
| Your situation | Pick | Why |
|---|---|---|
| Prototype or small app, minimal dependencies | Provider | Least code, shallow learning curve |
| New production app, mixed sync and async data | Riverpod 3 | Safe, testable, async built in |
| Large team, strict architecture, audit trails | Bloc | Events and states are explicit and loggable |
| Animation-heavy or dashboard UI with many small updates | Signals | Per-expression rebuilds |
| Existing Provider app that is growing painful | Riverpod | Migration tooling exists, concepts carry over |
| Team with web background (Preact, Solid, Angular) | Signals | Same mental model |
Mixing is legitimate. A common setup is Riverpod for app-level data and plain setState() or ValueNotifier for local widget state. Avoid running two global state libraries in the same feature.
Testing Each Approach
Your state logic should run without a widget tree. All four support that, with different tools.
// Riverpod: override dependencies in a ProviderContainer.
test('increments counter', () {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});
// Bloc: bloc_test gives a declarative API.
blocTest<CounterCubit, int>(
'emits [1] on increment',
build: CounterCubit.new,
act: (cubit) => cubit.increment(),
expect: () => [1],
);
Both tests run as plain Dart in milliseconds. For Provider, test the ChangeNotifier directly. For signals, test the controller class and read .value.
FAQ
Is Provider deprecated in 2026?
No. Provider is still published and works on current Flutter. It is the same author’s earlier package, and Riverpod is positioned as the successor. For new projects, most developers start with Riverpod. Existing Provider apps need no urgent rewrite.
Is Riverpod better than Bloc?
Neither is strictly better. Riverpod needs less boilerplate and handles async data with AsyncValue. Bloc enforces an event-to-state structure that suits large teams and apps needing detailed logs. Choose Riverpod for speed and flexibility, and Bloc for strictness.
Are Signals production-ready in Flutter?
Yes, for the reactive core. signals_flutter is on a stable 6.x line, and the API for signal, computed, effect, and Watch is small. The gap is architectural convention: you must decide how to scope, inject, and dispose signals yourself.
Can I use more than one state management library in a Flutter app?
Yes, but keep their roles distinct. Use one library for shared app state and setState() or ValueNotifier for local state. Running two global solutions in one feature adds confusion and duplicates logic.




