microsoft/reactor-commanding
Reactor commanding — actions that appear in multiple surfaces (menu + toolbar), need keyboard shortcuts, or need `CanExecute`. `Command`, `StandardCommand`, `UseCommand`, `CommandHost`. Use when wiring shared actions or shortcuts.
npx skills add https://github.com/microsoft/microsoft-ui-reactor --skill reactor-commanding
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.
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).
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.
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")), Text("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") })
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());
}
}
IsExecuting resets to false even if ExecuteAsync throws.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.
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.
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),
]);
}
}
Bridge existing MVVM/CommunityToolkit ICommand:
var cmd = CommandInterop.FromCommand(
viewModel.SaveCommand,
"Save",
icon: SymbolIcon("Save"),
accelerator: Accelerator(VirtualKey.S, VirtualKeyModifiers.Control));
MenuItem(cmd, item).
UseCommand for sync-only commands — it wastes hook slots.UseCommand conditionally — hooks must run in the same orderevery render.
Execute and ExecuteAsync on the same command — pick one.Take microsoft/reactor-commanding from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.