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 areOptional<T>; seemigration/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.
IsExecutingresets to false even ifExecuteAsyncthrows.- Call unconditionally — don't wrap in
if. DebounceMs > 0adds leading-edge debounce: the first fire runs, re-fires within the window are dropped, andIsEnabledis false for the duration so the button auto-disables then re-enables. Replaces theTask.Delay-to-absorb-double-clicks workaround. NeedsUseCommandto persist the window — a rawnew Command { DebounceMs = … }bound directly does not debounce. Works on syncExecutetoo (stays on the UI thread; noTask.Runhop). For async commands the disabled window is the longer of the lambda lifetime andDebounceMs.
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
UseCommandfor sync-only commands — it wastes hook slots. - Don't call
UseCommandconditionally — hooks must run in the same order every render. - Don't mix
ExecuteandExecuteAsyncon the same command — pick one.