Sparround

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: build returns a Future<T> and AsyncValue is 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).

dart
// 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 guideIn RiverpodNote
View model`Notifier` / `AsyncNotifier`One notifier per screen — a one-to-one relationship
The view model's stateThe `state` field (`AsyncValue<T>` or `T`)Must be immutable — updated via `copyWith`
CommandA public method on the notifierReturns `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) valueA separate providerKeeps 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. Using watch in 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