State management (Riverpod)
State and dependency injection are one mechanism: Riverpod 3.x. Each feature has one Notifier/AsyncNotifier/StreamNotifier ViewModel that exposes an immutable state value; dumb ConsumerWidgets read it. Widgets hold only ephemeral UI state; every durable mutation routes through one repository (the single write path). This skill covers the state-agnostic core first, then the Riverpod "how", then a Provider/ChangeNotifier appendix for the official Flutter-guide stack.
Read the reference for the task at hand:
references/ownership-and-lifecycle.md — the ownership table (which provider shape), family/autoDispose/onDispose rules, composition-root DI.
references/reads-and-side-effects.md — watch vs read vs listen vs select, the stale-closure hole, void action methods, ref.mounted/BuildContext guards.
references/riverpod3-api-and-testing.md — Riverpod 3.x API shifts (legacy moves, overrideWithValue, retry, ProviderContainer.test), what 2023 tutorials get wrong, and testing seams.
Run scripts/ban-legacy-providers.sh before a PR.
Non-negotiable rules
These hold regardless of the state library.
- One ViewModel per feature over one immutable state value. The state is a value type with value equality (
freezed/sealed + copyWith), never a mutable field bag. Cross-feature/shared state lives in a repository, not a ViewModel — two ViewModels owning the same fact is two facts that will disagree.
- State is private; mutate only through intent methods. No widget reaches into the state to poke a field. A view calls
notifier.rename(id, name); it never sets state. This is the one door through which state changes, so every change is reviewable in one place.
- Every transition assigns a new value. Value-equality listeners diff by value — a mutated-in-place instance reassigned to the same reference is missed and the UI silently stales. Always
copyWith into a new instance.
- Derive, don't store. A value computable from existing state (a total, a filtered count, a streak) is a getter or a stream projection, never a second stored field — a stored derivation is a second source of truth that drifts out of sync.
- Single write path. Every durable mutation is one repository method that persists transactionally and returns only after commit; the UI updates because a stream re-emits, not because you optimistically republished. Persist-before-publish makes crash-safety structural, not a matter of discipline.
- Unidirectional data flow. Intent → ViewModel → repository/service → new state → view. The view never mutates data it reads and never short-circuits back up the chain. Data flows down; events flow up.
- Depend on abstractions, injected — never constructed. A ViewModel receives its repositories/services/clock; it never
news a live collaborator and never holds a BuildContext. This is what makes it testable with fakes and swappable per flavor.
- Model async as an explicit state, not loose flags. One
AsyncValue (or a status enum + data + error) rendered exhaustively — never scattered isLoading/hasError booleans that can encode a half-set, impossible state.
- Cascade-clean references on delete. Deleting an entity must drop every reference to it (assignments, foreign keys, selection) — an item left pointing at a deleted parent is a latent crash. Never silently drop the orphan; re-home or unassign it explicitly.
- No
DateTime.now() in state logic. "Now" enters through an injected Clock (from package:clock) exposed as clockProvider, so time-dependent behaviour is deterministic in tests (clockProvider.overrideWithValue(Clock.fixed(t))). Never DateTime.now(), never a bespoke ClockService. service-boundary-and-native owns the clockProvider seam.
Riverpod: pick the right shape
Match the provider shape to what it owns (full table in references/ownership-and-lifecycle.md):
| Owns |
Shape |
| Per-feature presentation state over a live query |
StreamNotifier whose build() returns the repository stream |
| Per-feature presentation state, non-stream |
Notifier / AsyncNotifier over one immutable value |
| A reactive projection of the DB |
StreamProvider over a repository/DAO stream (never stored) |
An injected collaborator (repo, service, Clock) |
plain Provider (DI) |
| Per-entity/per-session state |
Notifier/AsyncNotifier/StreamNotifier .family + .autoDispose |
Default to hand-written providers (StreamNotifierProvider/AsyncNotifierProvider(...), .autoDispose/.family modifiers). @riverpod codegen is an optional convenience, not the default (see the reference). Riverpod 3.0 API only — base classes Notifier/AsyncNotifier/StreamNotifier; autoDispose/family are provider modifiers, never AutoDispose*/*Family base classes (removed in 3.0).
// features/task — one StreamNotifier ViewModel: build() returns the LIVE repo stream,
// so a committed write re-emits and the UI updates with no manual republish (D6).
class TaskListNotifier extends StreamNotifier<TaskListState> {
@override
Stream<TaskListState> build() {
final filter = ref.watch(taskFilterProvider); // in-session filter, its own Notifier
return ref.watch(taskRepositoryProvider).watchAll() // the live source of truth
.map((tasks) => TaskListState(tasks: tasks, filter: filter));
}
// Durable act routes through the single write path. void so the call site never drops
// the Future; no `state = ...` — the committed write makes watchAll() re-emit.
void complete(TaskId id) =>
unawaited(ref.read(taskRepositoryProvider).markComplete(id).catchError(_report));
void _report(Object error, StackTrace stack) {/* surface via a logger — never swallow */}
}
final taskListNotifierProvider =
StreamNotifierProvider.autoDispose<TaskListNotifier, TaskListState>(
TaskListNotifier.new);
// The reversible in-session filter is its own tiny Notifier (manual NotifierProvider).
class TaskFilterNotifier extends Notifier<TaskFilter> {
@override
TaskFilter build() => TaskFilter.all;
void set(TaskFilter f) => state = f; // watched by build() above; re-projects the stream
}
final taskFilterProvider =
NotifierProvider<TaskFilterNotifier, TaskFilter>(TaskFilterNotifier.new);
Riverpod: providers are DI (throwing seams)
A ViewModel reads collaborators from providers; the composition root wires the live impls once. A placeholder seam throws until overridden, so an un-wired dependency fails loudly at first read instead of silently constructing a real service inside a test.
// A seam: throws until a flavor main() overrides it. One live impl per flavor.
final databaseProvider = Provider<AppDatabase>(
(ref) => throw UnimplementedError('override databaseProvider in main()'),
);
final taskRepositoryProvider = Provider<TaskRepository>(
(ref) => TaskRepository(ref.watch(databaseProvider)), // inject, never `new` a live DB here
);
// main.dart — the ONLY place live collaborators are constructed.
void main() {
final db = AppDatabase.open();
runApp(ProviderScope(
overrides: [databaseProvider.overrideWithValue(db)],
child: const App(),
));
}
Before adding a provider, ask whether a constructor argument would do. Provider count going up is a smell, not progress — reach for family/scoping/codegen only when a plain provider genuinely cannot express the need.
Riverpod: reads — watch / read / listen
Full rationale in references/reads-and-side-effects.md.
ref.watch(p) / ref.watch(p.select((s) => s.field)) — in build(), for display. .select narrows the rebuild to one field. Never watch a whole controller at the top of a large widget.
ref.read(p.notifier) — in callbacks (onTap, onPressed). A read of a value in build() freezes on stale data; a watch in a callback rebuilds unexpectedly.
ref.listen(p, ...) — in build(), for side effects (navigate, snackbar) on change. Never fire a side effect directly from build().
class TaskCounter extends ConsumerWidget {
const TaskCounter({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final n = ref.watch(taskListNotifierProvider.select((s) => s.value?.openCount ?? 0));
return Text('$n');
}
}
The stale-closure hole: never capture a ref.watched value into an onTap closure — pass a stable key and resolve at tap time via ref.read. A captured value silently acts on the previous entity after a fast re-tap, and no lint catches it.
// WRONG — acts on a stale item after a re-render.
onTap: () => ref.read(p.notifier).complete(task),
// RIGHT — id is stable; resolve now.
onTap: () => ref.read(p.notifier).complete(task.id),
Action-path methods return void. An arrow closure onTap: () => notifier.doThing() that "returns" a Future satisfies neither discarded_futures nor unawaited_futures (target type is VoidCallback), so the Future and its error are dropped. A void intent method makes the hole unreachable; kick off async inside it with unawaited(_run().catchError(_report)). See async-safety.
Riverpod: lifecycle & disposal
- Per-entity/per-session state is
.family-keyed by a stable equatable value (an id or a @freezed args record), never a mutable object, and .autoDispose so heavy session state dies on unmount.
- App-scope singletons (database, engine, services) are plain
Provider — never autoDispose.
- Guard writes after an
await with ref.mounted (the Riverpod analogue of use_build_context_synchronously).
- Release owned resources in
ref.onDispose (streams, controllers, native handles). Riverpod owns a StreamProvider's subscription, so don't hand-manage it.
Riverpod: accessibility state does NOT go through providers
Read MediaQuery.boldTextOf(context) / .textScalerOf(context) / .highContrastOf(context) at build time in the widget, never via a provider. MediaQuery is already an InheritedWidget with correct-by-construction invalidation; routing it through a provider means either a BuildContext in a provider body or a one-frame-stale hand-sync. App/domain state via Riverpod; platform/a11y state via BuildContext. See accessibility-as-code.
Anti-patterns
- Legacy providers —
StateProvider / StateNotifierProvider / ChangeNotifierProvider (moved to flutter_riverpod/legacy.dart), or adding get_it / package:provider / Bloc alongside Riverpod. Two DI/state mechanisms is strictly worse than one.
- Business logic in a provider body. A provider is a thin wire; logic lives in the ViewModel, engine, and repository.
- Mutating the state value in place and reassigning the same instance — value-equality listeners won't see it.
ref.watch at the top of a big widget rebuilding the whole subtree on any field change. Use .select or a leaf ConsumerWidget.
- App/domain state in
setState(). setState/local State is for ephemeral UI only (a toggle, an in-flight animation flag).
- Optimistic republish before commit — a crash between republish and commit shows a fact the disk never held.
- A ViewModel that
news its collaborators, holds a BuildContext, navigates, or shows snackbars. Inject; publish state; let the view (or a router redirect) react.
family-keying on a mutable object, or autoDispose on an app-scope singleton, or keepAlive() to undo an autoDispose you shouldn't have added.
Definition of done
Related skills
flutter-architecture — where ViewModels, repositories, and the write path sit in the feature-first DAG.
error-handling-typed-results — the Result/Failure spine a repository method returns instead of throwing.
async-safety — the Future-drop hole, mounted guards, subscription/timer disposal.
persistence-drift — the Drift streams the StreamProviders project and the one-transaction-per-mutation write path.
service-boundary-and-native — the throwing-seam provider pattern for every side effect and native channel; owns the clockProvider (package:clock) seam these ViewModels read instead of DateTime.now().
widget-composition — the dumb ConsumerWidget Views that read this state.
accessibility-as-code — the split this skill co-owns: app/domain state via Riverpod, platform/a11y state read from MediaQuery/BuildContext, never a provider.
value-objects-money-and-units — value types that take a Clock param for deterministic time.
app-startup-and-bootstrap — the composition root where ProviderScope overrides are wired.
References
Provider / ChangeNotifier appendix
For the official Flutter-guide stack (package:provider + ChangeNotifier), the same ten rules hold; only the mechanism changes.
- ViewModel =
ChangeNotifier; new state = notifyListeners() after all fields are set (exactly once per logical change, never mid-update or in a loop).
- Private state, exposed immutably. Fields are
_private; expose read-only getters; return List.unmodifiable(...) — never hand out a mutable collection.
- DI = constructor injection of abstractions, wired with
Provider/ProxyProvider above the screen (auto-disposed on pop). Do not use get_it as the container.
- Reads =
context.select/Consumer/Selector for display, context.read in callbacks. Read in didChangeDependencies or callbacks, never initState.
- Guards = check a
_disposed flag before notifyListeners() after an await; cancel subscriptions and dispose owned controllers in dispose().
enum ViewStatus { idle, loading, ready, error }
class TaskListViewModel extends ChangeNotifier {
TaskListViewModel(this._repo); // injected abstraction
final TaskRepository _repo;
ViewStatus _status = ViewStatus.idle;
ViewStatus get status => _status;
List<Task> _tasks = const [];
List<Task> get tasks => List.unmodifiable(_tasks); // immutable view
int get openCount => _tasks.where((t) => !t.done).length; // derive, don't store
Future<void> load() async {
_status = ViewStatus.loading; notifyListeners();
try {
_tasks = await _repo.load();
_status = ViewStatus.ready;
} catch (_) {
_status = ViewStatus.error;
}
notifyListeners(); // exactly one, after all fields set
}
Future<void> complete(TaskId id) async {
await _repo.markComplete(id); // single write path
_tasks = [for (final t in _tasks) t.id == id ? t.copyWith(done: true) : t];
notifyListeners();
}
}
1---2name: state-management-riverpod3description: Enforces feature state as one Notifier/AsyncNotifier/StreamNotifier ViewModel over an immutable state value with value equality, private mutable state mutated only through intent methods, derive-don't-store, a single write path through a repository, and unidirectional data flow; Riverpod 3.x is DI (providers-as-collaborators, ProviderScope overrides per flavor, throwing seams), reads split ref.watch/.select for display vs ref.read(p.notifier) in callbacks vs ref.listen for side effects, async modeled as AsyncValue, per-entity state family-keyed + autoDispose, and stale-closure captures / legacy StateProvider-StateNotifierProvider-ChangeNotifierProvider / get_it / package:provider are banned. Use when adding state to a screen, writing a feature controller/ViewModel, wiring providers or DI, deriving a read model from a stream, or reviewing rebuild/state-leak/disposal/write-path issues.4---56# State management (Riverpod)78State and dependency injection are one mechanism: Riverpod 3.x. Each feature has one `Notifier`/`AsyncNotifier`/`StreamNotifier` ViewModel that exposes an **immutable** state value; dumb `ConsumerWidget`s read it. Widgets hold only ephemeral UI state; every durable mutation routes through one repository (the single write path). This skill covers the state-agnostic core first, then the Riverpod "how", then a Provider/ChangeNotifier appendix for the official Flutter-guide stack.910Read the reference for the task at hand:1112- `references/ownership-and-lifecycle.md` — the ownership table (which provider shape), family/autoDispose/onDispose rules, composition-root DI.13- `references/reads-and-side-effects.md` — watch vs read vs listen vs select, the stale-closure hole, `void` action methods, `ref.mounted`/`BuildContext` guards.14- `references/riverpod3-api-and-testing.md` — Riverpod 3.x API shifts (legacy moves, `overrideWithValue`, retry, `ProviderContainer.test`), what 2023 tutorials get wrong, and testing seams.1516Run `scripts/ban-legacy-providers.sh` before a PR.1718## Non-negotiable rules1920These hold regardless of the state library.21221. **One ViewModel per feature over one immutable state value.** The state is a value type with value equality (`freezed`/sealed + `copyWith`), never a mutable field bag. Cross-feature/shared state lives in a repository, not a ViewModel — two ViewModels owning the same fact is two facts that will disagree.232. **State is private; mutate only through intent methods.** No widget reaches into the state to poke a field. A view calls `notifier.rename(id, name)`; it never sets state. This is the one door through which state changes, so every change is reviewable in one place.243. **Every transition assigns a *new* value.** Value-equality listeners diff by value — a mutated-in-place instance reassigned to the same reference is missed and the UI silently stales. Always `copyWith` into a new instance.254. **Derive, don't store.** A value computable from existing state (a total, a filtered count, a streak) is a getter or a stream projection, never a second stored field — a stored derivation is a second source of truth that drifts out of sync.265. **Single write path.** Every durable mutation is one repository method that persists transactionally and returns **only after commit**; the UI updates because a stream re-emits, not because you optimistically republished. Persist-before-publish makes crash-safety structural, not a matter of discipline.276. **Unidirectional data flow.** Intent → ViewModel → repository/service → new state → view. The view never mutates data it reads and never short-circuits back up the chain. Data flows down; events flow up.287. **Depend on abstractions, injected — never constructed.** A ViewModel receives its repositories/services/clock; it never `new`s a live collaborator and never holds a `BuildContext`. This is what makes it testable with fakes and swappable per flavor.298. **Model async as an explicit state, not loose flags.** One `AsyncValue` (or a status enum + data + error) rendered exhaustively — never scattered `isLoading`/`hasError` booleans that can encode a half-set, impossible state.309. **Cascade-clean references on delete.** Deleting an entity must drop every reference to it (assignments, foreign keys, selection) — an item left pointing at a deleted parent is a latent crash. Never silently drop the orphan; re-home or unassign it explicitly.3110. **No `DateTime.now()` in state logic.** "Now" enters through an injected `Clock` (from `package:clock`) exposed as `clockProvider`, so time-dependent behaviour is deterministic in tests (`clockProvider.overrideWithValue(Clock.fixed(t))`). Never `DateTime.now()`, never a bespoke `ClockService`. `service-boundary-and-native` owns the `clockProvider` seam.3233## Riverpod: pick the right shape3435Match the provider shape to what it owns (full table in `references/ownership-and-lifecycle.md`):3637| Owns | Shape |38|---|---|39| Per-feature presentation state over a live query | `StreamNotifier` whose `build()` returns the repository stream |40| Per-feature presentation state, non-stream | `Notifier` / `AsyncNotifier` over one immutable value |41| A reactive projection of the DB | `StreamProvider` over a repository/DAO stream (never stored) |42| An injected collaborator (repo, service, `Clock`) | plain `Provider` (DI) |43| Per-entity/per-session state | `Notifier`/`AsyncNotifier`/`StreamNotifier` `.family` + `.autoDispose` |4445Default to **hand-written providers** (`StreamNotifierProvider`/`AsyncNotifierProvider(...)`, `.autoDispose`/`.family` modifiers). `@riverpod` codegen is an optional convenience, not the default (see the reference). Riverpod 3.0 API only — base classes `Notifier`/`AsyncNotifier`/`StreamNotifier`; `autoDispose`/`family` are provider modifiers, never `AutoDispose*`/`*Family` base classes (removed in 3.0).4647```dart48// features/task — one StreamNotifier ViewModel: build() returns the LIVE repo stream,49// so a committed write re-emits and the UI updates with no manual republish (D6).50class TaskListNotifier extends StreamNotifier<TaskListState> {51 @override52 Stream<TaskListState> build() {53 final filter = ref.watch(taskFilterProvider); // in-session filter, its own Notifier54 return ref.watch(taskRepositoryProvider).watchAll() // the live source of truth55 .map((tasks) => TaskListState(tasks: tasks, filter: filter));56 }5758 // Durable act routes through the single write path. void so the call site never drops59 // the Future; no `state = ...` — the committed write makes watchAll() re-emit.60 void complete(TaskId id) =>61 unawaited(ref.read(taskRepositoryProvider).markComplete(id).catchError(_report));6263 void _report(Object error, StackTrace stack) {/* surface via a logger — never swallow */}64}6566final taskListNotifierProvider =67 StreamNotifierProvider.autoDispose<TaskListNotifier, TaskListState>(68 TaskListNotifier.new);6970// The reversible in-session filter is its own tiny Notifier (manual NotifierProvider).71class TaskFilterNotifier extends Notifier<TaskFilter> {72 @override73 TaskFilter build() => TaskFilter.all;74 void set(TaskFilter f) => state = f; // watched by build() above; re-projects the stream75}7677final taskFilterProvider =78 NotifierProvider<TaskFilterNotifier, TaskFilter>(TaskFilterNotifier.new);79```8081## Riverpod: providers are DI (throwing seams)8283A ViewModel reads collaborators from providers; the composition root wires the live impls once. A placeholder seam **throws** until overridden, so an un-wired dependency fails loudly at first read instead of silently constructing a real service inside a test.8485```dart86// A seam: throws until a flavor main() overrides it. One live impl per flavor.87final databaseProvider = Provider<AppDatabase>(88 (ref) => throw UnimplementedError('override databaseProvider in main()'),89);9091final taskRepositoryProvider = Provider<TaskRepository>(92 (ref) => TaskRepository(ref.watch(databaseProvider)), // inject, never `new` a live DB here93);94```9596```dart97// main.dart — the ONLY place live collaborators are constructed.98void main() {99 final db = AppDatabase.open();100 runApp(ProviderScope(101 overrides: [databaseProvider.overrideWithValue(db)],102 child: const App(),103 ));104}105```106107Before adding a provider, ask whether a **constructor argument** would do. Provider count going up is a smell, not progress — reach for `family`/scoping/codegen only when a plain provider genuinely cannot express the need.108109## Riverpod: reads — watch / read / listen110111Full rationale in `references/reads-and-side-effects.md`.112113- `ref.watch(p)` / `ref.watch(p.select((s) => s.field))` — in `build()`, for display. `.select` narrows the rebuild to one field. Never `watch` a whole controller at the top of a large widget.114- `ref.read(p.notifier)` — in callbacks (`onTap`, `onPressed`). A `read` of a value in `build()` freezes on stale data; a `watch` in a callback rebuilds unexpectedly.115- `ref.listen(p, ...)` — in `build()`, for side effects (navigate, snackbar) on change. Never fire a side effect directly from `build()`.116117```dart118class TaskCounter extends ConsumerWidget {119 const TaskCounter({super.key});120 @override121 Widget build(BuildContext context, WidgetRef ref) {122 final n = ref.watch(taskListNotifierProvider.select((s) => s.value?.openCount ?? 0));123 return Text('$n');124 }125}126```127128**The stale-closure hole:** never capture a `ref.watch`ed value into an `onTap` closure — pass a stable key and resolve at tap time via `ref.read`. A captured value silently acts on the *previous* entity after a fast re-tap, and no lint catches it.129130```dart131// WRONG — acts on a stale item after a re-render.132onTap: () => ref.read(p.notifier).complete(task),133// RIGHT — id is stable; resolve now.134onTap: () => ref.read(p.notifier).complete(task.id),135```136137**Action-path methods return `void`.** An arrow closure `onTap: () => notifier.doThing()` that "returns" a `Future` satisfies neither `discarded_futures` nor `unawaited_futures` (target type is `VoidCallback`), so the Future *and its error* are dropped. A `void` intent method makes the hole unreachable; kick off async inside it with `unawaited(_run().catchError(_report))`. See `async-safety`.138139## Riverpod: lifecycle & disposal140141- Per-entity/per-session state is `.family`-keyed by a **stable equatable value** (an id or a `@freezed` args record), never a mutable object, and `.autoDispose` so heavy session state dies on unmount.142- App-scope singletons (database, engine, services) are plain `Provider` — never `autoDispose`.143- Guard writes after an `await` with `ref.mounted` (the Riverpod analogue of `use_build_context_synchronously`).144- Release owned resources in `ref.onDispose` (streams, controllers, native handles). Riverpod owns a `StreamProvider`'s subscription, so don't hand-manage it.145146## Riverpod: accessibility state does NOT go through providers147148Read `MediaQuery.boldTextOf(context)` / `.textScalerOf(context)` / `.highContrastOf(context)` **at build time in the widget**, never via a provider. `MediaQuery` is already an `InheritedWidget` with correct-by-construction invalidation; routing it through a provider means either a `BuildContext` in a provider body or a one-frame-stale hand-sync. App/domain state via Riverpod; platform/a11y state via `BuildContext`. See `accessibility-as-code`.149150## Anti-patterns151152- **Legacy providers** — `StateProvider` / `StateNotifierProvider` / `ChangeNotifierProvider` (moved to `flutter_riverpod/legacy.dart`), or adding `get_it` / `package:provider` / Bloc alongside Riverpod. Two DI/state mechanisms is strictly worse than one.153- **Business logic in a provider body.** A provider is a thin wire; logic lives in the ViewModel, engine, and repository.154- **Mutating the state value in place** and reassigning the same instance — value-equality listeners won't see it.155- **`ref.watch` at the top of a big widget** rebuilding the whole subtree on any field change. Use `.select` or a leaf `ConsumerWidget`.156- **App/domain state in `setState()`.** `setState`/local `State` is for ephemeral UI only (a toggle, an in-flight animation flag).157- **Optimistic republish before commit** — a crash between republish and commit shows a fact the disk never held.158- **A ViewModel that `new`s its collaborators, holds a `BuildContext`, navigates, or shows snackbars.** Inject; publish state; let the view (or a router redirect) react.159- **`family`-keying on a mutable object**, or `autoDispose` on an app-scope singleton, or `keepAlive()` to undo an `autoDispose` you shouldn't have added.160161## Definition of done162163- [ ] Feature state is one `Notifier`/`AsyncNotifier`/`StreamNotifier` over an immutable value; widgets hold only ephemeral UI state.164- [ ] State is private; every mutation goes through an intent method that assigns a new value; no in-place mutation.165- [ ] Derived values are getters/stream projections, not stored fields.166- [ ] Async is one `AsyncValue`/status rendered exhaustively; no loose loading/error booleans.167- [ ] Every durable mutation is a repository method that commits before returning; no optimistic pre-commit republish.168- [ ] Reads use `ref.watch`/`.select` for display, `ref.read(p.notifier)` in callbacks, `ref.listen` for side effects; no broad top-level watch; no captured-value closures.169- [ ] Collaborators injected via providers; seams throw until overridden; live impls wired once per flavor in `main`; no `BuildContext`/`DateTime.now()` in the ViewModel.170- [ ] Per-entity providers are `family`-keyed by a stable value and `autoDispose`d; app singletons are plain providers; resources released in `ref.onDispose`.171- [ ] No `legacy.dart`, `get_it`, `package:provider`, or Bloc import (`scripts/ban-legacy-providers.sh` passes).172173## Related skills174175- `flutter-architecture` — where ViewModels, repositories, and the write path sit in the feature-first DAG.176- `error-handling-typed-results` — the Result/Failure spine a repository method returns instead of throwing.177- `async-safety` — the Future-drop hole, `mounted` guards, subscription/timer disposal.178- `persistence-drift` — the Drift streams the `StreamProvider`s project and the one-transaction-per-mutation write path.179- `service-boundary-and-native` — the throwing-seam provider pattern for every side effect and native channel; owns the `clockProvider` (`package:clock`) seam these ViewModels read instead of `DateTime.now()`.180- `widget-composition` — the dumb `ConsumerWidget` Views that read this state.181- `accessibility-as-code` — the split this skill co-owns: app/domain state via Riverpod, platform/a11y state read from `MediaQuery`/`BuildContext`, never a provider.182- `value-objects-money-and-units` — value types that take a `Clock` param for deterministic time.183- `app-startup-and-bootstrap` — the composition root where `ProviderScope` overrides are wired.184185## References186187- [Riverpod docs](https://riverpod.dev)188- [Riverpod — Migrating to 3.0](https://riverpod.dev/docs/3.0_migration)189- [Flutter — App architecture guide (MVVM)](https://docs.flutter.dev/app-architecture/guide)190- [Flutter — Simple app state management](https://docs.flutter.dev/data-and-backend/state-mgmt/simple)191192## Provider / ChangeNotifier appendix193194For the official Flutter-guide stack (`package:provider` + `ChangeNotifier`), the same ten rules hold; only the mechanism changes.195196- **ViewModel** = `ChangeNotifier`; **new state** = `notifyListeners()` after all fields are set (exactly once per logical change, never mid-update or in a loop).197- **Private state, exposed immutably.** Fields are `_private`; expose read-only getters; return `List.unmodifiable(...)` — never hand out a mutable collection.198- **DI** = constructor injection of abstractions, wired with `Provider`/`ProxyProvider` above the screen (auto-disposed on pop). Do not use `get_it` as the container.199- **Reads** = `context.select`/`Consumer`/`Selector` for display, `context.read` in callbacks. Read in `didChangeDependencies` or callbacks, never `initState`.200- **Guards** = check a `_disposed` flag before `notifyListeners()` after an `await`; cancel subscriptions and dispose owned controllers in `dispose()`.201202```dart203enum ViewStatus { idle, loading, ready, error }204205class TaskListViewModel extends ChangeNotifier {206 TaskListViewModel(this._repo); // injected abstraction207 final TaskRepository _repo;208209 ViewStatus _status = ViewStatus.idle;210 ViewStatus get status => _status;211 List<Task> _tasks = const [];212 List<Task> get tasks => List.unmodifiable(_tasks); // immutable view213 int get openCount => _tasks.where((t) => !t.done).length; // derive, don't store214215 Future<void> load() async {216 _status = ViewStatus.loading; notifyListeners();217 try {218 _tasks = await _repo.load();219 _status = ViewStatus.ready;220 } catch (_) {221 _status = ViewStatus.error;222 }223 notifyListeners(); // exactly one, after all fields set224 }225226 Future<void> complete(TaskId id) async {227 await _repo.markComplete(id); // single write path228 _tasks = [for (final t in _tasks) t.id == id ? t.copyWith(done: true) : t];229 notifyListeners();230 }231}232```