RelayCommand is good. AsyncRelayCommand is better.

You know the RelayCommand pattern. It is a simple way to connect a view model’s method to a button in the UI. The main idea is straightforward: you wrap an Action in an object that implements ICommand. When the button is clicked, the Execute method runs your action. A separate CanExecute method tells the button when to enable or disable itself.

But here is the problem when you work with async methods. If you use a standard RelayCommand and give it a Task method, you lose control. You cannot know if the operation is still running. If the user clicks the button again, the command will start the same task again, even if the previous one is not finished. You also lose any exceptions that happen. They are swallowed, and you never see them.

Let us start with the basic RelayCommand. This is the simple version that works with synchronous methods.

public sealed class RelayCommand : ICommand
{
    private readonly Action<object?> _action;
    private readonly Func<object?, bool>? _canExecute;

    public RelayCommand(Action<object?> action, Func<object?, bool>? canExecute = null)
    {
        ArgumentNullException.ThrowIfNull(action);
        _action = action;
        _canExecute = canExecute;
    }

    public event EventHandler? CanExecuteChanged;

    // Checks if the command can execute
    public bool CanExecute(object? parameter) => _canExecute is null || _canExecute(parameter);

    // Notifies the UI that CanExecute result has changed
    public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);

    // Executes the command
    public void Execute(object? parameter)
    {
        if (!CanExecute(parameter))
        {
            return;
        }
        _action.Invoke(parameter);
    }
}

Now, let us build an AsyncRelayCommand step by step. We start with the version that does not support cancellation yet. The main changes are: we take a Func<object?, Task> instead of Action, we add an IsExecuting flag, and we handle exceptions properly.

public sealed class AsyncRelayCommand : ICommand
{
    private readonly Func<object?, Task> _action;
    private readonly Func<object?, bool>? _canExecute;
    private bool _isExecuting;

    public AsyncRelayCommand(Func<object?, Task> action, Func<object?, bool>? canExecute = null)
    {
        ArgumentNullException.ThrowIfNull(action);
        _action = action;
        _canExecute = canExecute;
    }

    public event EventHandler? CanExecuteChanged;

    // True when the command is running
    public bool IsExecuting
    {
        get => _isExecuting;
        private set
        {
            if (_isExecuting == value)
            {
                return;
            }
            _isExecuting = value;
            NotifyCanExecuteChanged();
        }
    }

    // Fired when an unhandled exception occurs
    public event EventHandler<ExceptionEventArgs>? UnhandledException;

    // Can execute only when not already running
    public bool CanExecute(object? parameter) => !IsExecuting && (_canExecute is null || _canExecute(parameter));

    public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);

    // Explicit interface implementation to hide the void version
    // The UI calls ICommand.Execute, which fires and forgets
    async void ICommand.Execute(object? parameter) => await Execute(parameter);

    // Public Task-returning method for view models
    // View models can await this to know when the operation completes
    public async Task Execute(object? parameter)
    {
        if (!CanExecute(parameter))
        {
            return;
        }

        try
        {
            IsExecuting = true;
            await _action.Invoke(parameter);
        }
        catch (Exception ex)
        {
            // If someone subscribed to UnhandledException, let them handle it
            var handler = UnhandledException;
            if (handler is null)
            {
                // No handler - rethrow to crash the app
                throw;
            }
            handler.Invoke(this, new ExceptionEventArgs(ex));
        }
        finally
        {
            IsExecuting = false;
        }
    }

    public sealed class ExceptionEventArgs(Exception exception) : EventArgs
    {
        public Exception Exception { get; } = exception;
    }
}

You might notice something interesting here. We have two Execute methods. One is the explicit interface implementation: async void ICommand.Execute. The other is a public method that returns Task. Why do we do this?

The ICommand interface expects void Execute. This is a problem for async methods. If we use async void directly, exceptions are hard to catch. The calling code cannot await the operation. But the UI framework expects this signature. So we keep the async void version only for the interface. It simply calls our public Task method and does not wait.

The public Task method is for the view model. When you call this command from your view model code, you can await it. You can know when the operation is done. You can also catch exceptions if you want. This gives you the best of both worlds: the UI can use the command normally, and your view model code can have full control.

The UnhandledException event is also important. In a typical application, you do not want your app to crash when something goes wrong. But you also do not want to put try-catch blocks in every command method. This event gives you one place to handle all exceptions from async commands. You can log the error, show a message to the user, or do any other recovery action. If no one subscribes to the event, the exception is rethrown. This is useful during development because you will see the error immediately.

Now, let us add cancellation support. This version gives the user a way to stop a long-running operation.

public sealed class AsyncRelayCommand : ICommand
{
    private readonly Func<object?, Task>? _action;
    private readonly Func<object?, CancellationToken, Task>? _cancellableAction;
    private readonly Func<object?, bool>? _canExecute;
    private CancellationTokenSource? _cancellationTokenSource;
    private bool _isExecuting;

    // Constructor for non-cancellable commands
    public AsyncRelayCommand(Func<object?, Task> action, Func<object?, bool>? canExecute = null)
    {
        ArgumentNullException.ThrowIfNull(action);
        _action = action;
        _canExecute = canExecute;
    }

    // Constructor for cancellable commands
    public AsyncRelayCommand(Func<object?, CancellationToken, Task> action, Func<object?, bool>? canExecute = null)
    {
        ArgumentNullException.ThrowIfNull(action);
        _cancellableAction = action;
        _canExecute = canExecute;
    }

    public event EventHandler? CanExecuteChanged;
    public event EventHandler<ExceptionEventArgs>? UnhandledException;
    public event EventHandler? Canceled;

    // Returns true if this command supports cancellation
    public bool CanCancel => _cancellableAction is not null;

    public bool IsExecuting
    {
        get => _isExecuting;
        private set
        {
            if (_isExecuting == value)
            {
                return;
            }
            _isExecuting = value;
            NotifyCanExecuteChanged();
        }
    }

    public bool CanExecute(object? parameter) => !IsExecuting && (_canExecute is null || _canExecute(parameter));

    public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);

    // Explicit interface implementation - UI calls this
    async void ICommand.Execute(object? parameter) => await Execute(parameter);

    // Public Task-returning method - view models can await this
    public async Task Execute(object? parameter)
    {
        if (!CanExecute(parameter))
        {
            return;
        }

        try
        {
            IsExecuting = true;

            // Non-cancellable execution
            if (_action is not null)
            {
                await _action.Invoke(parameter);
            }

            // Cancellable execution
            if (_cancellableAction is not null)
            {
                // Create a new cancellation token source for this execution
                _cancellationTokenSource = new CancellationTokenSource();
                await _cancellableAction.Invoke(parameter, _cancellationTokenSource.Token);
            }
        }
        catch (OperationCanceledException)
        {
            // Operation was canceled - fire the Canceled event
            Canceled?.Invoke(this, EventArgs.Empty);
        }
        catch (Exception ex)
        {
            var handler = UnhandledException;
            if (handler is null)
            {
                throw;
            }
            handler.Invoke(this, new ExceptionEventArgs(ex));
        }
        finally
        {
            IsExecuting = false;
            // Clean up the token source
            _cancellationTokenSource?.Dispose();
            _cancellationTokenSource = null;
        }
    }

    // Cancel the running operation
    public void Cancel()
    {
        if (!CanCancel)
        {
            throw new InvalidOperationException("This command does not support cancellation.");
        }
        _cancellationTokenSource?.Cancel();
    }

    public sealed class ExceptionEventArgs(Exception exception) : EventArgs
    {
        public Exception Exception { get; } = exception;
    }
}

This implementation gives you full control. When you create a command with the cancellable constructor, the button can call Cancel() to stop the operation. The CanCancel property tells the UI whether to show a cancel button. The Canceled event fires when the operation is canceled, so you can react to it if needed.

Why create your own implementation instead of using a framework? Just for fun. This is why I make all my open source projects. It is a good way to understand what is happening inside. You also get exactly what you need, without extra dependencies. The code is small, it fits in one file, and it gives you full control over execution, cancellation, and exception handling.