skip to content

In Flutter's architecture guide, how does a view use a view model's Command to show a spinner and ignore double taps while 'save draft' runs?

level: juniorimportance: should knowfreq 38%

answer

  1. a Command is a ChangeNotifier
  2. listen to the command, not the view model
  3. running drives the spinner
  4. execute returns early while running
  5. onPressed wired to execute

basics

~10 s

The view wraps the button in a ListenableBuilder listening to the command, shows a progress indicator while command.running is true, and wires onPressed to execute(), which returns early if the command is already running.

solid answer

~30 s

In the guide, a view model exposes each action as a `Command` object, such as `saveDraft`, instead of a plain method. A `Command` extends `ChangeNotifier`, so the view can pass it straight to `ListenableBuilder(listenable: viewModel.saveDraft, ...)`. Inside the builder, `saveDraft.running` decides between a `CircularProgressIndicator` and the button, and the button's `onPressed` calls `saveDraft.execute(draft)`. Double taps are handled twice over: `execute` returns immediately while `running` is true, and the view can also pass `onPressed: null` while running so the button looks disabled. Because each command has its own `running`, a 'save draft' spinner does not flash when a separate 'load' command runs.

code

dart · 31 lines
dart
import 'package:flutter/material.dart';

import 'command.dart'; // the guide's Command0 / Command1
import 'editor_view_model.dart';

class SaveDraftButton extends StatelessWidget {
  const SaveDraftButton({super.key, required this.viewModel});

  final EditorViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel.saveDraft,
      builder: (context, _) {
        final running = viewModel.saveDraft.running;
        return FilledButton(
          onPressed: running
              ? null // disabled while saving
              : () => viewModel.saveDraft.execute(viewModel.draft),
          child: running
              ? const SizedBox.square(
                  dimension: 16,
                  child: CircularProgressIndicator(strokeWidth: 2),
                )
              : const Text('Save draft'),
        );
      },
    );
  }
}

go deeper

for a junior

Show the wiring: ListenableBuilder on the command, running for the spinner, execute on the button.

for a middle

Explain why per-action state beats shared flags and how Command0 and Command1 differ.

for a senior

Point out rebuild scope and lifecycle: commands created once in the view model, listened to separately from the data.

for a principal

Decide whether the team adopts the guide's Command class, a package, or its state library's equivalent, and keep one convention.

## The problem a command solves In Flutter's architecture guide, a **view model** holds UI state and the actions a view can trigger. Once a screen has two actions, such as loading a blog post and saving a draft, a hand-written view model needs a separate `runningLoad`, `errorLoad`, `runningSave` and `errorSave` for each, plus guard code against running an action twice. The guide extracts that repeated code into a reusable **Command** class: one object per action that owns the action's `running`, `error` and `completed` state. ## What the view sees The guide's `Command` extends **`ChangeNotifier`**. That makes it a `Listenable`, so a view can listen to one action on its own: - `running` is `true` from the moment `execute` starts until the action finishes; - `error` is `true` when the last run finished with a failure; - `completed` is `true` when the last run finished successfully. The view model exposes the command as a public field, for example `late final Command1<void, Draft> saveDraft;`, and keeps the real work in a private method. ## Wiring the 'save draft' button In a blogging app's editor screen: 1. Wrap the button in `ListenableBuilder(listenable: viewModel.saveDraft, builder: ...)`. 2. In the builder, if `viewModel.saveDraft.running` is true, show a `CircularProgressIndicator` or a disabled button. 3. Otherwise show a `FilledButton` whose `onPressed` calls `viewModel.saveDraft.execute(currentDraft)`. The rest of the screen can listen to the view model itself with a second `ListenableBuilder`, so a save does not rebuild the editor text, and loading the post does not flash the save spinner. ## Why double taps are harmless The guide's `execute` begins with a guard: if the command is already running it **returns without doing anything**. A user who taps 'Save draft' three times quickly starts one save, not three. Disabling the button while `running` is still worth doing, because it tells the user why nothing happens. | Without a command | With a command | |---|---| | One `running` flag per action written by hand | `running` lives on each command | | Guard code copied into every method | `execute` refuses to start while running | | View listens to the whole view model | View can listen to just `saveDraft` | | Shared flags make spinners flash for the wrong action | Each action's state is separate | ## Commands with and without arguments The guide ships two variants: **`Command0`** for actions without arguments, whose `execute()` takes nothing, and **`Command1`** for actions with one argument, whose `execute(argument)` passes it through. 'Load post' is naturally a `Command0`; 'save draft' is a `Command1` that receives the draft. A view model can also start a command itself, for example `load = Command0(_load)..execute();` in its constructor so the screen loads on creation. ## Things juniors get wrong - Listening to the view model instead of the command, so the spinner never updates when only the command notifies. - Calling the private `_saveDraft` method from the view, which skips the running state entirely. - Building a new `Command` inside `build()`, which loses its state on every rebuild.

  • Why listen to the command rather than the whole view model?
    A command is its own `ChangeNotifier`. Listening to `viewModel.saveDraft` rebuilds only the button when the save starts or ends, and the spinner reflects that one action instead of any change anywhere in the view model.
  • What does Command1 add over Command0?
    An argument. `Command0.execute()` takes none; `Command1<T, A>.execute(A argument)` passes one value, such as the draft, to the wrapped action. The result type `T` is the same idea in both.

saying these in an interview costs you the question

  • Each tap on the button starts another save while one is running.
  • The view should call the private save method directly for speed.
  • A command cannot be listened to, so the view must poll it.
  • One shared running flag on the view model is enough for every action.
  • Commands should be created inside build so they are always fresh.