SuperAPI AI Directory

reactor-commanding

Frontend & websites

View repository

Updated 10-16 00:31

About this skill

Commanding in Reactor

Use Command when an action shows up in multiple surfaces (toolbar + menu + context menu), needs a keyboard shortcut, or needs CanExecute disabling. Use a bare Action for one-off button clicks with no reuse.

Controlled prop note: command examples may host controlled inputs such as TextBox(text, onChange). Factory call sites stay plain-valued, but direct reads from migrated element records are Optional<T>; see migration/050-optional-t.md.

Command record

var save = new Command
{
    Label = "Save",                                  // required
    Execute = () => Save(),                          // sync
    // OR:
    ExecuteAsync = async () => await SaveAsync(),    // async (wrap with UseCommand)
    CanExecute = hasChanges,                         // default true
    Icon = SymbolIcon("Save"),
    Description = "Save the document",               // tooltip + a11y
    Accelerator = Accelerator(VirtualKey.S, VirtualKeyModifiers.Control),
    AccessKey = "S",                                 // Alt+key
    DebounceMs = 0,                                  // >0 = leading-edge debounce (needs UseCommand)
};
// Computed: IsEnabled = CanExecute && !IsExecuting && !IsDebouncing

Command<T> is identical but Execute/ExecuteAsync receive a typed parameter — bind the parameter at the call site with MenuItem(cmd, item).

StandardCommand factory

Pre-built commands with correct labels, icons, and accelerators:

var cut    = StandardCommand.Cut(() => CutSelection());
var copy   = StandardCommand.Copy(() => CopySelection());
var paste  = StandardCommand.Paste(() => PasteFromClipboard());
var undo   = StandardCommand.Undo(() => Undo());
var redo   = StandardCommand.Redo(() => Redo());
var delete = StandardCommand.Delete(() => DeleteSelected());
var save   = StandardCommand.Save(async () => await SaveAsync()); // async overload
var open   = StandardCommand.Open(() => OpenFile());

// CanExecute parameter:
var cut2 = StandardCommand.Cut(() => CutSelection(), canExecute: hasSelection);

Also available: SelectAll, Close, Share, Play, Pause, Stop, Forward, Backward.

Command-aware DSL overloads

Define once, bind anywhere:

var save = StandardCommand.Save(() => SaveFile());

Button(save)              // label → content, execute → click, isEnabled → isEnabled
AppBarButton(save)        // + icon, accelerator, accessKey, description
MenuItem(save)            // + icon, accelerator, accessKey, description
MenuItem(deleteCmd, item) // parameterized: binds item as argument

Custom content (icon + label, stacked layouts) — build the element, then attach the command with the .Command() modifier. Works on Button, HyperlinkButton, RepeatButton, ToggleButton, and AppBarButton:

// Factory takes a text label only:
Button(save)

// .Command() binds execute + isEnabled + icon/accelerator/accessKey/description
// onto any custom-content clickable, and auto-disables while !command.IsEnabled:
Button(HStack(Icon(SymbolIcon("Save")), TextBlock("Save"))).Command(save)

.Command() re-applies IsEnabled on every update (so UseCommand toggling IsExecuting, or CanExecute changes, flow through) — never re-thread .IsEnabled(command.IsEnabled) by hand. It composes with .IsDisabledFocusable(): a disabled command keeps the button reachable via Tab instead of dropping it from the tab order.

Per-site overrides with with:

var delete = StandardCommand.Delete(() => DeleteSelected());
MenuItem(delete)                                          // "Delete"
MenuItem(delete with { Label = "Remove permanently" })
AppBarButton(delete with { Icon = SymbolIcon("Clear") })

UseCommand — async lifecycle

Only needed for commands with ExecuteAsync. Sync commands pass through unchanged.

class Editor : Component
{
    public override Element Render()
    {
        var saveCmd = UseCommand(StandardCommand.Save(async () =>
        {
            await SaveAsync();
        }));

        // saveCmd.Execute is now a sync wrapper around the async
        // saveCmd.IsExecuting is true while the async is in-flight
        // saveCmd.IsEnabled auto-flips to false while executing
        return HStack(
            Button(saveCmd),
            saveCmd.IsExecuting ? ProgressRing() : Empty());
    }
}
  • Consumes 2 hook slots; re-entrance guard ignores clicks while executing.
  • IsExecuting resets to false even if ExecuteAsync throws.
  • Call unconditionally — don't wrap in if.
  • DebounceMs > 0 adds leading-edge debounce: the first fire runs, re-fires within the window are dropped, and IsEnabled is false for the duration so the button auto-disables then re-enables. Replaces the Task.Delay-to-absorb-double-clicks workaround. Needs UseCommand to persist the window — a raw new Command { DebounceMs = … } bound directly does not debounce. Works on sync Execute too (stays on the UI thread; no Task.Run hop). For async commands the disabled window is the longer of the lambda lifetime and DebounceMs.

CommandHost — keyboard-scoped accelerators

Limits Accelerator registration to a subtree:

var save = StandardCommand.Save(() => SaveFile());
var undo = StandardCommand.Undo(() => UndoAction());

CommandHost([save, undo],
    VStack(
        TextBlock("Ctrl+S / Ctrl+Z only fire inside this region"),
        TextBox(value, onChange)))

Commands without an Accelerator are ignored by CommandHost.

Sharing commands via Context

Editor-provides / toolbar-consumes:

record EditorCommands(Command Save, Command Undo, Command Redo);
static readonly Context<EditorCommands?> EditorCtx = new(null);

class Editor : Component
{
    public override Element Render()
    {
        var save = UseCommand(StandardCommand.Save(async () => await SaveAsync()));
        var undo = StandardCommand.Undo(() => Undo());
        return TextBox(text, onChange)
            .Provide(EditorCtx, new EditorCommands(save, undo, redo));
    }
}

class Toolbar : Component
{
    public override Element Render()
    {
        var cmds = UseContext(EditorCtx);
        if (cmds is null) return Empty();
        return CommandBar(primaryCommands: [
            AppBarButton(cmds.Save),
            AppBarButton(cmds.Undo),
        ]);
    }
}

ICommand interop

Bridge existing MVVM/CommunityToolkit ICommand:

var cmd = CommandInterop.FromCommand(
    viewModel.SaveCommand,
    "Save",
    icon: SymbolIcon("Save"),
    accelerator: Accelerator(VirtualKey.S, VirtualKeyModifiers.Control));

Anti-patterns

  • Don't create commands inside loops. Define once, bind per item with MenuItem(cmd, item).
  • Don't UseCommand for sync-only commands — it wastes hook slots.
  • Don't call UseCommand conditionally — hooks must run in the same order every render.
  • Don't mix Execute and ExecuteAsync on the same command — pick one.