Sparround

The command pattern and side effects

In the official guide a command is an action the view model exposes to the view. A dedicated design-pattern page covers it and it solves three concrete problems:

1. Double taps. The user taps a button twice quickly and the action runs twice. A command's execute() checks if (_running) and returns early. 2. State multiplication. Without commands each action needs its own fields: runningLoad, errorLoad, runningEdit, errorEdit. A command keeps that state inside itself. 3. Triggering UI actions. Error dialogs, snackbars, navigation — a command's state (running, error, completed) makes them straightforward.

The official implementation provides Command0 (no arguments) and Command1<T, A> (one argument); they extend ChangeNotifier and expose running, error, completed, result and clearResult().

In Riverpod you do not need a separate Command class: the notifier's method plays that role and AsyncValue carries the running/error conditions. But the problems remain — double taps and repeated side effects happen in Riverpod too, only the fix differs.

dart
// Rəsmi dizayn pattern səhifəsindəki forma (sadələşdirilmiş).
typedef CommandAction0<T> = Future<Result<T>> Function();
typedef CommandAction1<T, A> = Future<Result<T>> Function(A);

abstract class Command<T> extends ChangeNotifier {
  bool _running = false;
  Result<T>? _result;

  bool get running => _running;
  bool get error => _result is Error;
  bool get completed => _result is Ok;
  Result<T>? get result => _result;

  /// Nəticə bir dəfə istifadə olunduqdan sonra təmizlənir —
  /// beləliklə snackbar/dialoq TƏKRARLANMIR.
  void clearResult() {
    _result = null;
    notifyListeners();
  }

  Future<void> _execute(CommandAction0<T> action) async {
    // İkiqat basılmanın qarşısı: əməliyyat gedirsə, erkən qayıdır.
    if (_running) return;

    _running = true;
    _result = null;
    notifyListeners();

    try {
      _result = await action();
    } finally {
      _running = false;
      notifyListeners();
    }
  }
}

final class Command0<T> extends Command<T> {
  Command0(this._action);
  final CommandAction0<T> _action;

  Future<void> execute() async => _execute(_action);
}

final class Command1<T, A> extends Command<T> {
  Command1(this._action);
  final CommandAction1<T, A> _action;

  Future<void> execute(A argument) async =>
      _execute(() => _action(argument));
}

// ── View model-də istifadə ──
class OrderViewModel extends ChangeNotifier {
  OrderViewModel({required OrderRepository repository})
      : _repository = repository {
    load = Command0(_load);
    cancel = Command1(_cancel);
  }

  final OrderRepository _repository;

  late final Command0<List<Order>> load;
  late final Command1<Order, String> cancel;

  Future<Result<List<Order>>> _load() => _repository.fetchMine();
  Future<Result<Order>> _cancel(String id) => _repository.cancel(id);
}

// ── View-da: ListenableBuilder command-a abunə olur ──
// ListenableBuilder(
//   listenable: viewModel.load,
//   builder: (context, child) {
//     if (viewModel.load.running) return const CircularProgressIndicator();
//     if (viewModel.load.error) return const ErrorView();
//     return child!;
//   },
//   child: ...,
// )

The shape of the official `Command` class — used directly in `ChangeNotifier`-based projects.

Solving the same problems in Riverpod. You do not need a separate Command class, but the three issues still need handling.

1. Double taps. Two mechanisms:

  • An isSubmitting flag in state (or a submitting variant in a sealed union) plus a check at the top of the method.
  • Disabling the button in the view: onPressed: state.isSubmitting ? null : () => …. Do both — even with the button disabled, a keyboard or another path remains.

2. Repeated side effects. Navigation, snackbars, dialogs and analytics events must happen once. The problem comes from the nature of build: it can run again at any moment. The right places in Riverpod:

  • ref.listen(provider, (prev, next) { … }) — safe inside build, fires only on change.
  • Or: the notifier's method returns an outcome and the view performs the effect after await. This variant reads more clearly, because cause and effect sit together.

3. The `context.mounted` check. After an async operation the widget may be gone from the tree. Anywhere you use context after an await, you need if (!context.mounted) return; — the most frequently omitted check, which shows up in production as "Looking up a deactivated widget's ancestor".

dart
// ══ Notifier: command + ikiqat basılma müdafiəsi ══
@riverpod
class CheckoutNotifier extends _$CheckoutNotifier {
  @override
  CheckoutState build() =>
      CheckoutState.editing(form: CheckoutForm.empty());

  Future<SubmitOutcome> submit() async {
    // 1) Müdafiə: yalnız editing/failed vəziyyətindən başlayır.
    final form = switch (state) {
      CheckoutEditing(:final form) => form,
      CheckoutFailed(:final form) => form,
      CheckoutSubmitting() => null,     // artıq gedir → təkrar yox
      CheckoutSubmitted() => null,      // artıq bitib
    };
    if (form == null) return SubmitOutcome.ignored;

    state = CheckoutState.submitting(form: form);

    final result = await ref.read(orderRepositoryProvider).place(form);

    switch (result) {
      case Ok(:final value):
        state = CheckoutState.submitted(order: value);
        // 2) Nəticə qaytarılır — naviqasiya BURADA edilmir.
        return SubmitOutcome.success(orderId: value.id);
      case Error(error: ValidationFailure(:final fieldErrors)):
        state = CheckoutState.editing(form: form, fieldErrors: fieldErrors);
        return SubmitOutcome.invalid;
      case Error(:final error):
        state = CheckoutState.failed(form: form, failure: error as Failure);
        return SubmitOutcome.failed;
    }
  }
}

// ══ View: yan effekt bir dəfə, mounted yoxlaması ilə ══
class CheckoutPage extends ConsumerWidget {
  const CheckoutPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(checkoutNotifierProvider);
    final isSubmitting = state is CheckoutSubmitting;

    return Scaffold(
      body: CheckoutFormView(state: state),
      bottomNavigationBar: FilledButton(
        // 3) Düymə də deaktiv edilir — iki səviyyəli müdafiə.
        onPressed: isSubmitting ? null : () => _submit(context, ref),
        child: isSubmitting
            ? const CircularProgressIndicator()
            : Text(AppLocalizations.of(context).submitOrder),
      ),
    );
  }

  Future<void> _submit(BuildContext context, WidgetRef ref) async {
    final outcome =
        await ref.read(checkoutNotifierProvider.notifier).submit();

    // 4) `await`-dan SONRA context işlədilir → mounted yoxlanılır.
    if (!context.mounted) return;

    final l10n = AppLocalizations.of(context);
    switch (outcome) {
      case SubmitSuccess(:final orderId):
        // Naviqasiya view-da: bir dəfə, açıq şəkildə.
        context.go('/orders/$orderId?justCreated=true');
      case SubmitOutcome.invalid:
        // Sahə xətaları artıq state-dədir; əlavə snackbar lazım deyil.
        break;
      case SubmitOutcome.failed:
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(l10n.submitFailed)),
        );
      case SubmitOutcome.ignored:
        break;
    }
  }
}

// ══ Alternativ: ref.listen ilə (state dəyişikliyinə reaksiya) ══
// build içində:
// ref.listen(checkoutNotifierProvider, (previous, next) {
//   if (next case CheckoutSubmitted(:final order)) {
//     context.go('/orders/${order.id}');
//   }
// });
// Bu variant "state-ə görə naviqasiya" məntiqi üçün uyğundur
// (deep link, avtomatik yönləndirmə); düymə basılması üçün isə
// yuxarıdaki `await` variantı daha aydındır.

A command in Riverpod: guarding against double taps, returning an outcome, and running the side effect once.

Do not run side effects in `build`. This is the most dangerous mistake:

if (state.isSubmitted) { context.go('/success'); } — in build's body.

build can run again at any moment (a parent rebuilt, the keyboard opened, the screen rotated). The result: two navigations, overlapping dialogs, duplicated analytics events. Sometimes worse — navigating during build trips a Flutter assertion.

The right places: ref.listen (Riverpod), inside a callback (onPressedawait → effect), or addPostFrameCallback (rarely).

Practice. Build one write operation (submit or delete) end to end: a double-tap guard in the notifier, an outcome enum, a context.mounted check in the view, and different behaviour for success and failure.

Then test it: tap the button three times quickly. Done means: one request reaches the network (visible in logs), one snackbar appears, and navigation happens once.

📚 Sources and documentation

  • The command patternofficialdocs.flutter.dev

    The source for this topic: `Command0`/`Command1`, `running`/`error`/`completed`, `clearResult()`, and the double-tap guard.

  • Riverpod: the rules of refofficialriverpod.dev

    `ref.listen` — the right place for a side effect; `ref.read` inside commands.

  • Optimistic state patternofficialdocs.flutter.dev

    Updating the UI before the write completes, and rolling back on failure.

  • Architecture recommendations: commandsofficialdocs.flutter.dev

    Why commands are recommended: preventing rendering errors and standardising events.