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.
// 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
isSubmittingflag in state (or asubmittingvariant 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 insidebuild, 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".
// ══ 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 (onPressed → await → 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.