Shared Action Registry
Shared action registry
When a UI exposes the same set of actions through multiple surfaces — command palette, global keybindings, help dialog, settings page — the surfaces will drift. The cure is one registry that all of them derive from.
The drift problem
Before mxr's web app had a registry, three independent surfaces held the same list:
CommandPalette.tsx— items shown inCmd-Kkeymap.ts— global keybindings viatinykeysshortcutHints.ts— help dialog rows
Drift was inevitable and shipped real bugs. One example: g a routed to archive in the keymap but was labelled "Analytics" in the palette. Two of the three surfaces agreed; the third lied. The lie persisted because nobody owned reconciling them.
This is the M-surfaces-times-N-actions problem at small scale. Three surfaces × eighty actions = 240 entries to keep in sync. The number is small enough that "we'll just be careful" sounds plausible. It doesn't survive contact with feature work.
The registry shape
One Action type, one catalog, every consumer derives from it:
type Action = {
id: string; // stable kebab, e.g. "mail.archive"
label: string;
description?: string;
group: ActionGroup;
icon?: LucideIcon;
shortcut?: ShortcutChord;
paletteOnly?: boolean;
when?: (ctx: ActionContext) => boolean;
run: (ctx: ActionContext) => void | Promise<void>;
};
Consumers:
- Command palette:
useActionsByGroup(ctx)filters viawhen - Keybindings: registry-derived chord map fed to
tinykeys - Help dialog:
useActionShortcutHints(ctx) - Settings keybindings page: derived live — no hardcoded list
Adding a new action means adding one entry. The four surfaces update automatically. There's no "remember to also update X" footnote because there's no X to update.
Predicates and runners split the right way
when predicates are composable pure functions: onRoute, onPane, withSelection, withFocusedThread. Multiple chained with and(...). This stays testable and serializable.
run functions never call React hooks. They reach into stores via getNavigateRef() and useModals.getState(). The reason: a runner has to fire from outside React (the tinykeys callback), from inside React (the palette), and programmatically (other code paths). Hooks would limit it to one of those.
Bundle impact and lazy escape hatch
The catalog imports every feature's runners eagerly. ~80 actions × ~200 bytes ≈ 16 KB pre-gzip. If this grows past a few percent of the main chunk, the escape hatch is to lazy-load runners with dynamic import() keyed by action.id while keeping Action metadata eager so palette filtering stays sync. The pattern survives the optimization because the registry is the seam.
Where this generalizes
Any app where the same actions appear in multiple invocation surfaces. Electron apps with a menu bar + keyboard + command palette. VS Code with the command palette + keybindings + menus. Mobile apps with toolbar + long-press menu + voice. The number of surfaces only grows; the drift cost compounds. Single registry, every surface derived.
The trigger for adopting it: the first time a surface lies and you notice it through a user bug rather than through your own testing. After that, the registry is cheaper than the next bug.
See also
- Headless Core + Multiple Clients — the protocol-level analogue: one canonical surface, many derived consumers
- Mxr — where this registry lives (
apps/web/src/lib/actions/)