Files
awesome-copilot/skills/mvvm-toolkit/references/relaycommand-cookbook.md
T
Alvin Ashcraft e7755069e9 WinUI plugin enhancements and add MVVM Toolkit skill (#1643)
* WinUI plugin enhancements and mvvm toolkit skill

* Split mvvm-toolkit skill for slimming
2026-05-11 11:29:33 +10:00

6.7 KiB

RelayCommand cookbook

Recipes for RelayCommand / AsyncRelayCommand and the [RelayCommand] generator. Defaults to the generator-attribute style; manual constructor patterns are listed at the bottom for advanced cases.


Sync command

[RelayCommand]
private void IncrementCounter() => Counter++;
<Button Command="{x:Bind ViewModel.IncrementCounterCommand}" Content="+1"/>

Sync command with parameter

[RelayCommand]
private void RemoveItem(Item item) => Items.Remove(item);
<Button Command="{x:Bind ViewModel.RemoveItemCommand}"
        CommandParameter="{x:Bind Item}" Content="Remove"/>

The generator picks IRelayCommand<Item> based on the parameter type.

Sync command with CanExecute

[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SubmitCommand))]
private string? text;

[RelayCommand(CanExecute = nameof(CanSubmit))]
private void Submit() => service.Submit(Text!);

private bool CanSubmit() => !string.IsNullOrWhiteSpace(Text);

[NotifyCanExecuteChangedFor] raises CanExecuteChanged automatically whenever Text changes — without it, the button stays disabled even after the user types.


Async command

[RelayCommand]
private async Task LoadAsync()
{
    Items.Clear();
    foreach (var item in await service.GetItemsAsync())
        Items.Add(item);
}

Bind the UI to LoadCommand.IsRunning to show a spinner:

<ProgressRing IsActive="{x:Bind ViewModel.LoadCommand.IsRunning, Mode=OneWay}"/>

Async command with cancellation

[RelayCommand(IncludeCancelCommand = true)]
private async Task DownloadAsync(CancellationToken token)
{
    try
    {
        await using var stream = await http.GetStreamAsync(url, token);
        // ...
    }
    catch (OperationCanceledException)
    {
        // Expected — user cancelled.
    }
}
<Button Command="{x:Bind ViewModel.DownloadCommand}" Content="Download"/>
<Button Command="{x:Bind ViewModel.DownloadCancelCommand}" Content="Cancel"/>

DownloadCancelCommand.CanExecute is automatically wired to DownloadCommand.IsRunning.

Async command with concurrency

[RelayCommand(AllowConcurrentExecutions = true)]
private async Task PingAsync(string host)
{
    await pingService.PingAsync(host);
}

Default (AllowConcurrentExecutions = false) reports the command as disabled while a previous execution is pending. Set to true for fire-and-forget patterns where overlapping invocations are safe.

Async command that surfaces errors to UI

[RelayCommand(FlowExceptionsToTaskScheduler = true)]
private async Task SyncAsync(CancellationToken token)
{
    await syncService.SyncAsync(token);
}
<TextBlock Text="{x:Bind ViewModel.SyncCommand.ExecutionTask.Exception, Mode=OneWay}"/>

Without FlowExceptionsToTaskScheduler = true, an uncaught exception in SyncAsync will crash the app (mirroring sync commands). With it, the exception is surfaced through ExecutionTask and bubbles to TaskScheduler.UnobservedTaskException.

Showing async command status

<StackPanel>
    <ProgressRing IsActive="{x:Bind ViewModel.SyncCommand.IsRunning, Mode=OneWay}"/>
    <TextBlock Text="{x:Bind ViewModel.SyncCommand.ExecutionTask.Status, Mode=OneWay}"/>
</StackPanel>

Useful properties on IAsyncRelayCommand:

Property Type Purpose
ExecutionTask Task? The currently running (or last completed) task
IsRunning bool true while a task is in flight
CanBeCanceled bool true if the wrapped method takes a CancellationToken
IsCancellationRequested bool true after Cancel() was called for the in-flight task

Methods:

Method Purpose
Cancel() Signals the active CancellationToken
NotifyCanExecuteChanged() Re-evaluates CanExecute and raises CanExecuteChanged

Forwarding attributes to the generated command property

[RelayCommand]
[property: JsonIgnore]
[property: Description("Saves the current document")]
private Task SaveAsync() => repo.SaveAsync(Text!);

The generator emits SaveCommand with [JsonIgnore] and [Description] applied — useful when the VM is serialized.


Manual RelayCommand / AsyncRelayCommand

Reach for the manual constructors when you need:

  • A command composed from multiple methods or dynamically rebuilt
  • A CanExecute predicate built from external observables
  • An ICommand instance held in a field (rare; the generator's lazy property is enough for almost every case)
public sealed class CounterViewModel : ObservableObject
{
    public CounterViewModel()
    {
        IncrementCommand = new RelayCommand(() => Counter++);
        DecrementCommand = new RelayCommand(() => Counter--, () => Counter > 0);
    }

    [ObservableProperty]
    private int counter;

    public IRelayCommand IncrementCommand { get; }
    public IRelayCommand DecrementCommand { get; }
}
public sealed class DownloadViewModel : ObservableObject
{
    public DownloadViewModel()
    {
        DownloadCommand = new AsyncRelayCommand(DownloadAsync, () => CanDownload);
    }

    [ObservableProperty]
    private bool canDownload = true;

    public IAsyncRelayCommand DownloadCommand { get; }

    private async Task DownloadAsync()
    {
        CanDownload = false;
        try { await http.DownloadAsync(); }
        finally { CanDownload = true; }
    }
}

Trigger CanExecute re-evaluation manually with SomeCommand.NotifyCanExecuteChanged().


Task.WhenAll from a single command

[RelayCommand]
private async Task SyncAllAsync(CancellationToken token)
{
    var tasks = providers.Select(p => p.SyncAsync(token));
    await Task.WhenAll(tasks);
}

If you want individual progress tracking per provider, expose one command per provider instead.


Common mistakes

  1. async void instead of async Task. The generator only wraps Task-returning methods as IAsyncRelayCommand. async void becomes a sync RelayCommand and exceptions are unobserved.
  2. Forgetting [NotifyCanExecuteChangedFor]. The button stays disabled even though CanX() would now return true.
  3. Calling Cancel() on a non-cancellable command. Only commands whose wrapped method accepts a CancellationToken honor Cancel().
  4. Catching OperationCanceledException and rethrowing as a different type. Loses cancellation semantics; ExecutionTask.IsCanceled will be false. Let OperationCanceledException propagate (or return).
  5. Awaiting IAsyncRelayCommand.ExecuteAsync() from inside another [RelayCommand]. Prefer calling the underlying method directly to avoid double-wrapping the cancellation/concurrency semantics.