Skill: State Management
Overview
This toolkit's default Flutter presentation pattern is Riverpod: a Notifier/AsyncNotifier owns an immutable state value, widgets ref.watch it, and the framework rebuilds only the widgets that read what changed. It is the Flutter analogue of the toolkit's iOS MVVM rule — notifier ≈ ViewModel, AsyncValue/sealed state ≈ the state enum. Riverpod is also the DI graph (see dependency_injection.md), so one mechanism covers both state and wiring. BLoC/Cubit is a supported alternative — see the section below — but a codebase picks one and does not mix.
Use Cases
- Screen state that has real loading/error/empty branches (anything backed by I/O).
- State shared across routes (session, cart, feature flags) without passing it down the tree.
- Derived state that must recompute when an input changes (
ref.watchanother provider). - Anything you want to unit-test without pumping a widget.
Best Practices
- State is immutable. Never mutate in place; assign a new value (
state = state.copyWith(…)). Riverpod compares by identity, so an in-place mutation simply will not rebuild. - Model async screens with
AsyncValueviaAsyncNotifier— it encodes loading/data/error in one type and gives youwhen/switchexhaustiveness for free. Use a hand-written sealed state class when a screen has statesAsyncValuecannot express (e.g.LoadedplusRefreshingWithStaleData). - Never scatter
bool isLoading/String? errorflags. They permit impossible combinations; a sealed union does not. ref.watchinbuild();ref.readinside callbacks and notifier methods;ref.listenfor side effects (snackbars, navigation) — never navigate frombuild().- Scope rebuilds with
select:ref.watch(cartProvider.select((c) => c.itemCount))rebuilds on count changes only. - Use
AsyncNotifier.build()for the initial load — do not kick off loading frominitState. Wrap mutations inAsyncValue.guardso errors land in state instead of the zone. - Prefer
autoDispose(the default for the modern codegen) and reach forkeepAlivedeliberately, with a comment explaining the lifetime. - Use
familyfor parameterized providers (articleProvider(id)), and pass ids, not objects. - Notifiers contain presentation logic only. Business rules belong in use cases.
Anti-Patterns
- ❌ Mutating a list/map inside state (
state.items.add(x)) — no rebuild fires. - ❌
ref.readinsidebuild()(state changes silently stop rebuilding the widget). - ❌
ref.watchinside a callback or a notifier method (creates a stale, un-disposed dependency). - ❌ Calling
Navigator/showDialogfrom a notifier — notifiers must not importBuildContext. - ❌ Business logic (validation, pricing, retry policy) living in the notifier instead of a use case.
- ❌
StateNotifier/ChangeNotifierfor new code —Notifier/AsyncNotifieris the current API. - ❌ One god-provider holding the whole app's state; split by concern.
- ❌ Mixing Riverpod and BLoC in the same codebase.
Checklist
- [ ] Every screen's state is an
AsyncValueor a sealed union — no loose boolean flags. - [ ] State objects are immutable with
copyWith; no in-place mutation. - [ ]
ref.watchonly inbuild/other providers;ref.readonly in callbacks. - [ ] Mutations use
AsyncValue.guard(or set an explicit failure state) — errors never escape. - [ ] Rebuild scope narrowed with
selectwhere the state object is large. - [ ] Notifier has no
BuildContext, noNavigator, no directDio/database access. - [ ] Every notifier has a unit test using
ProviderContainerwith overridden dependencies. - [ ]
keepAliveis used deliberately and commented; everything else auto-disposes.
Dart Examples
// presentation/articles_notifier.dart
class ArticlesNotifier extends AsyncNotifier<List<Article>> {
@override
Future<List<Article>> build() => ref.watch(getArticlesProvider)();
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => ref.read(getArticlesProvider)());
}
}
final articlesProvider =
AsyncNotifierProvider<ArticlesNotifier, List<Article>>(ArticlesNotifier.new);// presentation/articles_screen.dart — every state handled, no flags
class ArticlesScreen extends ConsumerWidget {
const ArticlesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final articles = ref.watch(articlesProvider);
return switch (articles) {
AsyncData(:final value) when value.isEmpty => const EmptyView(),
AsyncData(:final value) => ArticleList(articles: value),
AsyncError(:final error) => ErrorView(
message: '$error',
onRetry: () => ref.read(articlesProvider.notifier).refresh(),
),
_ => const Center(child: CircularProgressIndicator()),
};
}
}// ✅ a hand-written sealed union when AsyncValue is not expressive enough
sealed class CheckoutState {
const CheckoutState();
}
final class CheckoutIdle extends CheckoutState {
const CheckoutIdle();
}
final class CheckoutSubmitting extends CheckoutState {
const CheckoutSubmitting();
}
final class CheckoutSucceeded extends CheckoutState {
const CheckoutSucceeded(this.orderId);
final String orderId;
}
final class CheckoutFailed extends CheckoutState {
const CheckoutFailed(this.failure);
final Failure failure;
}// ❌ impossible states are representable ✅ they are not
class BadState { // sealed CheckoutState above
bool isLoading = false; // — loading AND failed cannot
String? error; // coexist by construction
List<Article>? articles; //
}When to Choose BLoC/Cubit Instead
Riverpod is the default. Pick BLoC/Cubit when:
- The team already runs BLoC across a large codebase — consistency beats the migration cost.
- You want an explicit, replayable event stream for audit or analytics: BLoC's
Event → Bloc → Statepipeline records intent, whichNotifiermethod calls do not. - Your org's review standards already encode
bloc_test,BlocObserver, and event naming.
If you choose BLoC, the toolkit's rules translate directly: states remain immutable sealed classes, one Cubit/Bloc per screen, no BuildContext inside it, business rules stay in use cases, and DI moves to get_it+injectable or RepositoryProvider. Use Cubit unless you actually need the event log — Bloc costs an event class per interaction. Do not mix the two libraries in one codebase; the resulting two sources of truth are the failure mode this rule exists to prevent.
Common Interview Questions
- Why does mutating a list inside Riverpod state fail to rebuild the UI?
ref.watchvsref.readvsref.listen— when is each correct?- What problem does
selectsolve, and what is the cost of not using it? - How does
AsyncValue.guardchange error handling compared with atry/catchin the widget? - When does
autoDisposedispose, and when would youkeepAlive? - What does BLoC give you that Riverpod does not, and is it worth an event class per interaction?
AI Implementation Notes
- Default to
AsyncNotifier+AsyncValuefor any screen backed by I/O; reach for a hand-written sealed class only whenAsyncValuecannot express the state. - Generate exhaustive
switchover the state — never anif (isLoading)ladder. - Never generate
StateNotifierorChangeNotifierfor new code. - Detect the existing pattern before writing: if
flutter_blocis inpubspec.yamland Riverpod is not, follow BLoC and say so rather than introducing a second state library. - Always emit a notifier unit test alongside, using
ProviderContainerwith overridden providers. - iOS counterpart:
../ios/mvvm.md. - Related:
dependency_injection.md,../../../standards/flutter_standards.md,../../testing/flutter/unit_testing.md.