State Management
Flutter state management — selecting and using Provider, Riverpod, Bloc, GetX with architectural patterns
State Management
Flutter has no officially blessed state management solution; the community has produced many. This page compares the mainstream options and gives selection guidance.
State Categories
Not every state needs "managing". Categorize by scope first, then pick the simplest tool:
| State Type | Example | Recommended Tool |
|---|---|---|
| Ephemeral | Text input value, selected tab | StatefulWidget + setState |
| Widget-local | Form controllers, animations | StatefulWidget |
| Cross-Widget shared | Theme, current user | InheritedWidget / Provider |
| App-level business state | Cart, order list | Riverpod / Bloc |
| Persisted state | Login, settings | Repository + persistence + state layer |
Anti-pattern: shoving every state into a global store. If setState solves it, you do not need a state library. State libraries exist to share state across Widgets and to decouple business logic — not to replace setState.
Comparing the Options
| Solution | Learning Curve | Compile-time Safety | Test-friendly | Community | Recommended For |
|---|---|---|---|---|---|
setState | ⭐ | - | Medium | - | Ephemeral state, demos |
Provider | ⭐⭐ | Low (runtime lookup) | High | Maintained | Small/medium projects, migrations |
Riverpod | ⭐⭐⭐ | High (compile-time) | High | Active | First choice for new projects |
Bloc | ⭐⭐⭐⭐ | High | High | Active | Larger teams, strict architecture |
GetX | ⭐⭐ | Low | Low | Controversial | Discussed below |
MobX | ⭐⭐⭐ | Medium | Medium | Stable | If you come from React/MobX |
signals | ⭐⭐ | High | High | Emerging | Fans of reactive signals |
Selection Decision Tree
New project?
├── Yes → Riverpod (compile-time safety + DevTools integration)
└── No → Stick with what the project already uses; migration cost is high
└── Team ≥ 5 + strict architectural needs? → Consider BlocProvider
The original officially-recommended solution; wraps InheritedWidget.
// 1. Define the model
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
// 2. Inject
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: MyApp(),
)
// 3. Consume
context.watch<CounterModel>().count // subscribe in build
context.read<CounterModel>().increment() // read in event callbacks
context.select<CounterModel, int>((m) => m.count) // granular subscriptionPros: simple API, official endorsement, plentiful docs.
Cons:
Provider.of<T>errors only surface at runtime- Nested MultiProvider becomes hard to read
- Complex dependency graphs are painful to express
Riverpod
The Provider author's rewrite. While ProviderScope still uses an InheritedWidget under the hood for lookup, the public API decouples providers from BuildContext and gains compile-time safety. The recommended modern API is Notifier / AsyncNotifier (with riverpod_generator); StateNotifierProvider is now soft-deprecated and kept only for migration.
// 1. Define the provider — modern Notifier API (Riverpod 2.x)
final counterProvider = NotifierProvider<Counter, int>(Counter.new);
class Counter extends Notifier<int> {
@override
int build() => 0; // initial state
void increment() => state++;
}
// 2. Use under a ProviderScope
runApp(ProviderScope(child: MyApp()));
// 3. Subscribe in a ConsumerWidget
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Column(children: [
Text('$count'),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: const Text('+1'),
),
]);
}
}Powerful Features
Auto-derivation:
final userProvider = FutureProvider<User>((ref) => api.fetchUser());
final userNameProvider = Provider<String>((ref) {
return ref.watch(userProvider).maybeWhen(
data: (u) => u.name,
orElse: () => 'Loading',
);
});Family providers (parameterized; family is also available on NotifierProvider / AsyncNotifierProvider):
final orderProvider = FutureProvider.family<Order, String>(
(ref, id) => api.fetchOrder(id),
);
ref.watch(orderProvider('order-123'));autoDispose: clean up automatically when nobody is subscribed:
final searchProvider = FutureProvider.autoDispose.family<List<Item>, String>(
(ref, query) => api.search(query),
);ref.listen for side effects on change:
ref.listen<int>(counterProvider, (prev, next) {
if (next > 10) showSnackBar('too many!');
});Riverpod Generator
riverpod_generator cuts boilerplate via annotations:
@riverpod
Future<User> user(UserRef ref, String id) async {
return await ref.read(apiProvider).fetchUser(id);
}
// userProvider is generatedBloc
An event-stream-based architecture: opinionated and consistent. Suited to large projects.
import 'package:equatable/equatable.dart';
// 1. Define events
sealed class CounterEvent {}
class Increment extends CounterEvent {}
// 2. Define state — MUST implement value equality, otherwise Bloc dedupes
// by identity and emit() won't trigger rebuilds for "same-shape" states
class CounterState extends Equatable {
final int count;
const CounterState(this.count);
@override
List<Object?> get props => [count];
}
// 3. Bloc
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(const CounterState(0)) {
on<Increment>((event, emit) => emit(CounterState(state.count + 1)));
}
}
// 4. Inject
BlocProvider(create: (_) => CounterBloc(), child: MyApp())
// 5. Consume
BlocBuilder<CounterBloc, CounterState>(
builder: (_, state) => Text('${state.count}'),
)
context.read<CounterBloc>().add(Increment())Cubit (lightweight Bloc)
No events, just methods on the cubit:
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}Strengths
- Enforces unidirectional data flow
- Events and states are value types — easy to test
- BlocObserver gives global observability for logging / debugging
- Team conventions are uniform
Trade-offs
- Lots of boilerplate (mitigated with
freezed) - Even simple state needs an Event
- Steeper learning curve
A Note on GetX
GetX bundles state, routing, DI, i18n, and utilities into a single package, which is initially attractive for small teams. The trade-offs to weigh:
- Service-locator-style globals make tests harder (you must reset
Getbetween cases) and obscure dependencies that would be explicit with Provider/Riverpod. - Reactive primitives (
.obs) mutate values in place; type inference and refactoring tools cannot follow them as cleanly asNotifier/StateNotifier. - Mixed concerns: putting routing, DI, and state in one package conflicts with the "small composable libraries" direction the rest of the ecosystem has taken.
Use it if your team is already productive with it; for a new project today, Riverpod or Bloc is a safer long-term bet.
Architectural Patterns
MVVM with Provider / Riverpod
View (Widget)
↓ ref.watch
ViewModel (StateNotifier / ChangeNotifier)
↓ calls
UseCase / Service
↓ calls
Repository
↓
Data Source (Remote / Local)Layer responsibilities:
| Layer | Owns | Should NOT |
|---|---|---|
| View | Rendering, capturing user events | Business logic, HTTP calls |
| ViewModel | Page state, UseCase invocation | UI details, knowing about Widgets |
| UseCase | A single business use case ("place order", "refund") | Knowing about UI |
| Repository | Abstracts data sources | Business rules |
| Data Source | Protocol details (Dio, SharedPreferences) | Business, caching policy |
Bloc + Clean Architecture
Presentation (Bloc + Widget)
↓
Domain (Entity + UseCase + Repository interface)
↓
Data (Repository impl + Model + Data Source)The Domain layer has no Flutter dependency — pure Dart. Easy to share across platforms and extremely testable.
Data Fetching and Caching
Recommended Pattern: Repository + State Layer
class OrderRepository {
final OrderApi _api;
final OrderCache _cache;
Future<Order> get(String id) async {
final cached = await _cache.get(id);
if (cached != null && !cached.isStale) return cached;
final fresh = await _api.fetch(id);
await _cache.put(fresh);
return fresh;
}
}Server State vs Client State
Borrowing from React Query's framing:
- Server state: comes from an API; has staleness; needs retries / caching / refetching
- Client state: pure UI (modal open/closed, tab selected); unrelated to the server
Riverpod's FutureProvider + autoDispose + family covers most server-state needs. Flutter equivalents to React Query include fl_query and Riverpod-based caching solutions.
Persistence and Restoration
Simple Preferences
shared_preferences — key-value, lightweight.
Structured Data
hive / isar / drift — local databases.
State Restoration
Restoring to the original page after the OS kills the app:
MaterialApp(
restorationScopeId: 'root', // enable
...
)
class _MyState extends State<MyWidget> with RestorationMixin {
final _count = RestorableInt(0);
@override
String get restorationId => 'counter';
@override
void restoreState(RestorationBucket? old, bool initial) {
registerForRestoration(_count, 'count');
}
}Testing
Provider / Riverpod Tests
Riverpod ships ProviderContainer so tests need no Widget at all:
test('counter increments', () {
final container = ProviderContainer();
expect(container.read(counterProvider), 0);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});Bloc Tests
The bloc_test package provides blocTest():
blocTest<CounterBloc, CounterState>(
'emits [1] when Increment is added',
build: () => CounterBloc(),
act: (bloc) => bloc.add(Increment()),
expect: () => [CounterState(1)],
);Anti-Patterns
1. Global mutable singletons
// Anti-pattern
class GlobalState {
static int counter = 0;
}Untestable, unobservable, no mock injection. Use a state library instead.
2. Calling APIs directly in Widgets
Putting API calls in onPressed makes mocking, retrying, and caching impossible. Always go through a Repository layer.
3. ChangeNotifier holding BuildContext
ChangeNotifier should be pure logic — it must not know about Widgets. Trigger navigation / dialogs via callbacks or Streams handed to the UI layer.
4. State scattered across multiple sources
If the same state lives in both local setState and a Provider, drift is inevitable. Apply the single source of truth principle.
Rules of thumb
- Single page, ephemeral state → setState
- Simple sharing across 2–3 widgets → InheritedWidget / Provider
- App-level business state → Riverpod
- Large team + strict architecture → Bloc
- Do not adopt Bloc + Clean Architecture in a small project to "look professional" — you will drown in boilerplate