Notifier as view model: the presentation layer in Riverpod
In the official guide a view model does two things: it converts repository data into screen state and it retains that state across view rebuilds. In Riverpod that role is played by Notifier / AsyncNotifier.
The mapping:
AsyncNotifier<T>— for screens that load data:buildreturns aFuture<T>andAsyncValueis managed for you.Notifier<T>— for synchronous state: a form, a filter, a selection.- The command role is played by the notifier's methods:
refresh(),submit(),toggleFilter(…).
A notifier's responsibilities:
- Call a repository (or a use case).
- Convert the outcome into screen state.
- Accept user actions (commands).
- Hold screen-scoped state: the filter, the selected item, the page number.
What is not a notifier's responsibility: HTTP, JSON, caching policy (those are the data layer), business rules (a domain model or a use case), navigation and dialogs (the view's job — the next topic).
// fayl: lib/ui/orders/order_list_notifier.dart
part 'order_list_notifier.g.dart';
@riverpod
class OrderListNotifier extends _$OrderListNotifier {
static const _pageSize = 20;
@override
Future<OrderListState> build() async {
// İlk yükləmə: repository çağırılır, nəticə ekran state-inə çevrilir.
final orders = await _repository.fetchMine(pageSize: _pageSize);
return OrderListState(
orders: orders,
filter: OrderFilter.all,
hasMore: orders.length == _pageSize,
);
}
OrderRepository get _repository => ref.read(orderRepositoryProvider);
// ── Command: filtr dəyişikliyi (yalnız lokal state) ──
void setFilter(OrderFilter filter) {
final current = state.value;
if (current == null) return;
state = AsyncValue.data(current.copyWith(filter: filter));
// DİQQƏT: filtr serverə getmirsə, siyahı yenidən yüklənmir.
// Süzgəc `build`-də hesablanır — bax: aşağıdaki `visibleOrders`.
}
// ── Command: növbəti səhifə ──
Future<void> loadMore() async {
final current = state.value;
if (current == null || !current.hasMore || current.isLoadingMore) return;
// Köhnə siyahı ekranda qalır, altında spinner görünür.
state = AsyncValue.data(current.copyWith(isLoadingMore: true));
final next = await _repository.fetchMine(
page: current.page + 1,
pageSize: _pageSize,
);
state = AsyncValue.data(current.copyWith(
orders: [...current.orders, ...next],
page: current.page + 1,
hasMore: next.length == _pageSize,
isLoadingMore: false,
));
}
// ── Command: yazma əməliyyatı, nəticəni view-a qaytarır ──
Future<CancelOutcome> cancel(String orderId) async {
final result = await _repository.cancel(orderId);
switch (result) {
case Ok(:final value):
// Optimistik deyil: server cavabı ilə siyahı yenilənir.
final current = state.value;
if (current != null) {
state = AsyncValue.data(current.copyWith(
orders: [
for (final o in current.orders)
if (o.id == orderId) value else o,
],
));
}
return CancelOutcome.success;
case Error(error: OrderNotCancellableFailure()):
return CancelOutcome.notCancellable;
case Error():
return CancelOutcome.failed;
}
}
}
// ── Törəmə dəyər: süzgəcdən keçmiş siyahı ──
// Ayrı provider kimi yazmaq notifier-i sadə saxlayır və
// yalnız filtr dəyişdikdə yenidən hesablanır.
@riverpod
List<Order> visibleOrders(Ref ref) {
final state = ref.watch(orderListNotifierProvider).value;
if (state == null) return const [];
return switch (state.filter) {
OrderFilter.all => state.orders,
OrderFilter.active =>
state.orders.where((o) => o.status.isActive).toList(),
OrderFilter.completed =>
state.orders.where((o) => o.status == OrderStatus.completed).toList(),
};
}A complete notifier: loading, filtering, pagination and one write. Note the absence of HTTP, JSON and `BuildContext`.
| In the official guide | In Riverpod | Note |
|---|---|---|
| View model | `Notifier` / `AsyncNotifier` | One notifier per screen — a one-to-one relationship |
| The view model's state | The `state` field (`AsyncValue<T>` or `T`) | Must be immutable — updated via `copyWith` |
| Command | A public method on the notifier | Returns `Future<void>` or an outcome (`enum`) |
| The view model's dependencies | `ref.watch(…)` / `ref.read(…)` | `watch` rebuilds on change; `read` is one-shot |
| Loading / error state | `AsyncValue` | An exception thrown from `build` becomes `AsyncError` automatically |
| A derived (computed) value | A separate provider | Keeps the notifier simple; recomputed only when a dependency changes |
`ref.watch` or `ref.read`? This is the most common mistake when writing a notifier, and the rule is simple:
- Inside `build` —
ref.watch(…): the notifier is rebuilt when a dependency changes. If the repository provider changes (an override, for instance) the state updates automatically. - Inside methods (commands) —
ref.read(…): a one-shot call. Usingwatchin a command creates a subscription and causes unexpected rebuilds.
Keep derived values in separate providers. Filtering, sorting and totals bloat a notifier when kept inside it. A separate provider (visibleOrders, cartTotal) buys two things: the notifier stays simple, and the computation runs only when a dependency changes.
One screen, one notifier. In the official guide the view-to-view-model relationship is one-to-one. If you want to share a notifier between two screens, the shared part actually belongs to a repository — shared data is the data layer's job.
Practice. Write one screen's notifier: a repository call in build, two commands (one changing local state, one calling the repository) and one derived provider (a filter or a total).
Then write a test for it, using ProviderContainer.test(overrides: [repositoryProvider.overrideWithValue(Fake…)]).
Done means: the notifier file contains no http, jsonDecode, BuildContext, Navigator or ScaffoldMessenger, and the test raises no widget at all.
📚 Sources and documentation
- Case study: the UI layerofficialdocs.flutter.dev
A view model's responsibilities and its one-to-one relationship with the view.
- Riverpod: provider typesofficialriverpod.dev
Writing function and class providers with `@riverpod`, and the `build` method.
- Riverpod: the rules of refofficialriverpod.dev
The difference between `ref.watch`, `ref.read` and `ref.listen`, and where each belongs.
- Architecture recommendations: MVVMofficialdocs.flutter.dev
Why using view models is a top-priority recommendation.