DraftThis article is currently in draft mode

Let's Build Composable Keyboard Navigation Together

Prerequisites: We'll assume you're comfortable with React and TypeScript. We'll introduce Entity-Component-System (ECS) concepts as we go - no prior game dev experience needed!

Time to read: ~15 minutes
What we'll learn: How to build keyboard navigation using composable plugins that don't know about each other

The Problem We're Solving

We're building a complex UI, and we need keyboard shortcuts everywhere. Our text editor needs Cmd+B for bold, our cards need arrow keys for navigation, our buttons need Enter to activate.

We could write one giant keyboard handler that knows about every component. But we've been down that road before - it becomes a tangled mess the moment we need context-sensitive shortcuts or want to test things in isolation. Every time we add a component, we're editing that massive switch statement. Every time a shortcut conflicts, we're debugging spaghetti code.

Let's try something different. We'll build a system where components declare what they need, and a plugin wires everything together automatically. No tight coupling, no spaghetti code, and every piece testable in isolation.

What We're Building

We'll create three interactive cards, each with different keyboard shortcuts. When we focus a card, its shortcuts become active. Press ↑/↓ to navigate between cards, then try each card's unique actions.

By the end, we'll understand how four small building blocks compose into a working keyboard system - without any of them knowing about the others.

Our Approach: Entities, Components, and Plugins

We're borrowing a pattern from game development called Entity-Component-System (ECS):

  • Entity = A unique identifier for a thing in the system, not a class or instance
  • Component = Data attached to an entity via a component type key
  • Plugin = Behavior that queries entities with specific component combinations and reacts to changes

The mapping to React:

ReactECS
─────────────────────────────────────
Component instanceEntity (just a UID)
Props/state shapeComponent (data attached to UID)
Context + useEffectPlugin (reactive behavior)
useStateAtom (Jotai reactive state)

The key insight: Components are just data. Plugins add behavior by querying for that data. Nothing is tightly coupled.

Let's see this in practice.

Try It Out First

Before diving into theory, let's play with what we're building:

Watch how the demo responds. Notice that only the focused card's shortcuts work - the others are "dormant" until focused.

Now let's understand how we built this.

Our Four Building Blocks

Let's break down our keyboard system into four composable pieces.

Block 1: Making Things Focusable

First, we need to mark which entities can receive focus. We'll create a CFocusable component:

export class CFocusable extends World.Component("focusable")<
  CFocusable,
  {
    handler: Handler<ExitDirection>;
    hasDirectFocusAtom: Atom<boolean>;
  }
>() {
}

What this gives us:

  • hasDirectFocusAtom: A reactive boolean that's true when this specific entity has focus
  • handler: Called when focus enters from a direction (we'll use this later for spatial nav)

Let's use it:

const buttonUID = uid(null, null, "my-button");

world.addEntity(buttonUID, ButtonEntity, {
  focusable: CFocusable.of({
    hasDirectFocusAtom: atom(false),
    handler: () => handled`button focused`,
  }),
});

Notice: Our component doesn't know HOW focus works, just that it CAN be focused. It's pure data.

Block 2: Tracking Which Entity Has Focus

We have focusable entities, but we need to track which one currently has focus. That's a singleton concern - only one entity can have focus at a time.

We'll create a "Unique" (a singleton component):

export class UCurrentFocus extends World.Unique("currentFocus")<
  UCurrentFocus,
  { activeFocusAtom: PrimitiveAtom<UID | null> }
>() {}

What this gives us:

  • activeFocusAtom: Holds the UID of whichever entity currently has focus (or null)

How it connects:

// When we focus a card:
const focusUnique = world.getUniqueOrThrow(UCurrentFocus);
world.store.set(focusUnique.activeFocusAtom, cardUID);

// Anywhere else in our app:
const focusedEntityUID = world.store.get(focusUnique.activeFocusAtom);

Notice: CFocusable and UCurrentFocus don't import each other. They communicate through atoms. The CFocusable Plugin (which we'll see soon) is what wires them together.

Block 3: Declaring Actions

Now we need entities to declare what keyboard actions they support:

export type AnyAction = {
  label: string;
  defaultKeybinding: DefaultKeyCombo;
  description?: string;
  icon?: any;
  self?: boolean;
  hideFromLastPressed?: boolean;
};
type AnyBindables = Record<string, AnyAction>;

export type ActionEvent = { target: UID };

export namespace CActions {
  export type Bindings<T extends AnyBindables> = {
    [P in keyof T]: Handler<ActionEvent> | Falsey;
  };
}

export type ActionBindings<T extends AnyBindables = AnyBindables> = {
  bindingSource: DevString;
  registryKey: ActionRegistryKey<T>;
  bindingsAtom: Atom<CActions.Bindings<T>>;
};

export class ActionRegistryKey<T extends AnyBindables = AnyBindables> {
  constructor(
    public readonly key: string,
    public readonly meta: { source: DevString; sectionName: string },
    public readonly bindables: T,
  ) {}
}

export class CActions extends World.Component("actions")<CActions, ActionBindings[]>() {
  static bind<T extends AnyBindables>(key: ActionBindings<T>) {
    return CActions.of([key as ActionBindings]);
  }

  static merge(...bindings: Array<ActionBindings | ActionBindings[]>) {
    const out: ActionBindings[] = [];
    for (const b of bindings) {
      if (Array.isArray(b)) {
        out.push(...b);
      } else {
        out.push(b);
      }
    }
    return CActions.of(out);
  }

  static defineActions<T extends AnyBindables>(
    key: string,
    meta: { source: DevString; sectionName: string },
    actions: T,
  ): ActionRegistryKey<T> {
    return new ActionRegistryKey(key, meta, actions);
  }
}

What this gives us:

  • A way to define available actions (defineActions)
  • A way to bind handlers to those actions per entity
  • Actions are just metadata: label, key binding, description

Let's define some actions for our cards:

const CardActions = CActions.defineActions(
  "card-actions",
  { source: dev`Card actions`, sectionName: "Card Actions" },
  {
    delete: {
      label: "Delete Card",
      defaultKeybinding: "X" as const,
      description: "Remove this card",
    },
    edit: {
      label: "Edit Card",
      defaultKeybinding: "E" as const,
      description: "Edit card content",
    },
  },
);

Now attach handlers to a specific card entity:

world.addEntity(cardUID, CardEntity, {
  actions: CActions.bind({
    bindingSource: dev`Card 1 actions`,
    registryKey: CardActions,
    bindingsAtom: atom({
      delete: () => {
        alert("Deleted!");
        return handled`delete`;
      },
      edit: () => {
        alert("Editing!");
        return handled`edit`;
      },
    }),
  }),
});

Notice: We defined the action schema once, then bound different handlers per entity. One card might delete, another might archive. Same action definition, different behavior.

Block 4: Wiring It All Together - ActionsPlugin

Here's where the magic happens. We need something that:

  1. Listens for keyboard events
  2. Finds the currently focused entity
  3. Matches keys to actions
  4. Executes the handler

That's what our ActionsPlugin does:

export const ActionsPlugin = World.definePlugin({
  name: dev`ActionsPlugin`,
  setup: (build) => {
    const { store } = build;

    // Track the currently focused entity
    const currentFocusAtom = atom((get) =>
      pipeNonNull(get(build.getUniqueAtom(UCurrentFocus)), (a) => get(a.activeFocusAtom)),
    );

    const rootUIDAtom = atom<UID | null>(null);
    const currentDispatchSpotAtom = atom((get) => get(currentFocusAtom) ?? get(rootUIDAtom));

    const handleOnce = new WeakSet<KeyboardEvent>();
    build.addUnique(UKeydownRootHandler, {
      handler(reason, keyboardEvent) {
        if (handleOnce.has(keyboardEvent)) return Outcome.Passthrough;
        handleOnce.add(keyboardEvent);
        if (keyboardEvent.defaultPrevented) return Outcome.Passthrough;
        const world = store.get(build.worldAtom);
        if (!world) return Outcome.Passthrough;
        const dispatchFromUID = store.get(currentDispatchSpotAtom);
        if (!dispatchFromUID) return Outcome.Passthrough;
        // Walk up the parent chain looking for keydown handlers
        const result = CParent.dispatch(
          dev`keydown from root`.because(reason),
          world,
          dispatchFromUID,
          CKeydownHandler,
          reason,
          keyboardEvent,
        );
        // Prevent default browser behavior when we handle the key
        if (result !== Outcome.Passthrough) {
          keyboardEvent.preventDefault();
        }
        return result;
      },
      rootUIDAtom,
    });

Here's what happens when we press a key:

User presses "X"
ActionsPlugin.UKeydownRootHandler receives event
Query: Which entity has focus? (from UCurrentFocus)
Walk up parent chain: Does this entity have CKeydownHandler?
Match key "X" to action "delete"
Call the handler we bound earlier
preventDefault() so browser doesn't scroll

The beautiful part? None of these components import each other. The plugin queries the world: "Give me the focused entity. Does it have CActions? Great, wire up keyboard handling for it."

    // Provide keydown handler for entities with CActions
    build.onEntityCreated(
      {
        requires: [CActions],
        provides: [CKeydownHandler],
      },
      (uid, { actions }) => {
        const combinedCombosAtom = atom((get) => {
          type ComboData = {
            actionKey: string;
            handler: (reason: DevString, event: ActionEvent) => Outcome;
          } & AnyAction;

          const combosMap = new Map<string, ComboData[]>();

          for (const actionSet of actions) {
            const resolvedBindings = get(actionSet.bindingsAtom);
            for (const [actionKey, maybeHandler] of Object.entries(resolvedBindings)) {
              if (!maybeHandler) continue;
              const bindable = actionSet.registryKey.bindables[actionKey];
              if (!bindable) continue;
              const defaultKey = bindable.defaultKeybinding;
              const combo = normalizedKeyCombo(defaultKey, ENV_KEYBOARD_KIND).normalized;
              const comboData: ComboData = {
                actionKey,
                handler: maybeHandler,
                ...bindable,
              };
              const list = combosMap.get(combo);
              if (!list) {
                combosMap.set(combo, [comboData]);
              } else {
                list.push(comboData);
              }
            }
          }
          return combosMap;
        });

        const keydownHandler = CKeydownHandler.of({
          handler(reason, event) {
            // Omit shift for letter keys so "Shift+X" matches "X"
            const combos = addModifiersToKeyCombo(ENV_KEYBOARD_KIND, event, true);
            if (event.defaultPrevented) return Outcome.Passthrough;
            const combosMap = store.get(combinedCombosAtom);

            for (const combo of combos) {
              const comboDatas = combosMap.get(combo.normalized);
              if (!comboDatas) continue;
              for (const comboData of comboDatas) {
                const outcome = comboData.handler(dev`Key combo pressed: ${combo.normalized}`.because(reason), {
                  target: uid,
                });
                if (outcome !== Outcome.Passthrough) {
                  return outcome;
                }
              }
            }
            return Outcome.Passthrough;
          },
        });

        return { keydownHandler };
      },
    );

This is the actual per-entity handler creation. When we add an entity with CActions, the plugin automatically:

  • Reads all action definitions
  • Normalizes key combos (so "X" and "Shift-X" both match)
  • Creates a CKeydownHandler that matches keys to handlers
  • Plugs it into the event system

We don't call any of this ourselves. It Just Works™.

What We Learned

Let's step back and appreciate what we built:

✅ We Can Test Everything In Isolation

Want to test if "X" triggers delete? No React needed:

const world = createTestWorld();
const cardUID = addCardEntity(world, {
  onDelete: mockFn,
});

// Simulate focus
world.store.set(focusAtom, cardUID);

// Simulate keypress
rootHandler.handler(dev`test`, { key: "x" });

expect(mockFn).toHaveBeenCalled();

✅ Components Are Composable

A simple button might only have CFocusable. A rich text editor adds CActions with 50 shortcuts. A card adds both plus CSelectable. Mix and match:

// Simple button
world.addEntity(uid, SimpleButton, {
  focusable: CFocusable.of({...}),
});

// Rich editor
world.addEntity(uid, RichEditor, {
  focusable: CFocusable.of({...}),
  actions: CActions.merge(
    TextFormattingActions,    // Bold, italic, etc.
    BlockActions,             // Lists, headings
    ClipboardActions,         // Cut, copy, paste
  ),
});

✅ Everything Is Observable

All state lives in Jotai atoms. DevTools can show us:

  • Which entity has focus right now
  • What actions are available
  • When each action was last pressed

✅ Nothing Is Tightly Coupled

Look at what we didn't do:

  • ❌ Import KeyboardManager in every component
  • ❌ Call registerShortcut() imperatively
  • ❌ Have components know about each other
  • ❌ Write glue code to connect pieces

Instead:

  • ✅ Components declare data ("I can be focused", "I have actions")
  • ✅ Plugins react to component presence ("Oh, you have both? Let me wire you up")
  • ✅ Everything communicates through atoms
  • ✅ Adding a new entity requires zero changes to existing code

When Should We Use This Approach?

Let's be honest about trade-offs.

This pattern shines when:

  • ✅ We're already using ECS architecture in our app (like we do in Phosphor)
  • ✅ We have complex nesting and need context-sensitive shortcuts
  • ✅ We want every piece testable in isolation
  • ✅ Our app has 10+ different shortcut contexts
  • ✅ We value composition over simplicity

Consider simpler alternatives when:

  • ❌ We're building a small app with <20 shortcuts
  • ❌ We want minimal bundle size (this adds ~30KB with Jotai + ECS)
  • ❌ Our team isn't familiar with reactive state patterns
  • ❌ We just need basic "hotkey → function" mapping

Simpler Alternatives We Considered

ApproachBundle SizeLearning CurveTest IsolationContext-Sensitive
ECS (Ours)~30KBHighExcellentExcellent
tinykeys2KBLowGoodManual
React Context0KBMediumMediumGood
Mousetrap8KBLowPoorManual

For our use case (complex editor with nested contexts), the composition benefits outweigh the complexity cost. For most apps, a 2KB library like tinykeys is probably the right call.

Tracing a Keypress Together

Let's walk through exactly what happens when we press "X" to delete a card. This demystifies the "magic":

📍 Step 1: DOM Event (keyboard-demo.entrypoint.tsx:29)
   document.addEventListener('keydown', ...)
   Event fires with event.key = "x"


📍 Step 2: Root Handler (ActionsPlugin.ts:33-42)
   UKeydownRootHandler.handler() receives event
   Check currentDispatchSpotAtom: Is anything focused?
   Result: Card 2 has focus


📍 Step 3: Parent Walk (ActionsPlugin.ts:44-55)
   CParent.dispatch() walks up the entity tree
   Current: Card 2 → Does it have CKeydownHandler? YES ✓


📍 Step 4: Key Normalization (ActionsPlugin.ts:105-107)
   addModifiersToKeyCombo("x", event, omitShift=true)
   "x" → "X" (uppercase)
   No modifiers, final combo: "X"


📍 Step 5: Action Lookup (ActionsPlugin.ts:110-115)
   combosMap.get("X") → [{action: "delete", handler: fn}]
   Call handler(dev`Key combo`, {target: card2UID})


📍 Step 6: Our Handler (createKeyboardDemo.ts:83)
   onDelete() runs
   alert("Deleted Card 2!")
   Returns: handled`delete`


📍 Step 7: Prevent Default (ActionsPlugin.ts:50-52)
   outcome !== Passthrough, so:
   event.preventDefault() ← stops browser scroll
   Return "handled" to stop propagation

Key insight: Notice how information flows through atoms and component queries, never through direct imports or method calls. That's the decoupling in action.

What's Next?

Now that we understand composable keyboard navigation, we can:

  • Add spatial navigation (arrow keys navigate a 2D grid)
  • Build focus trapping for modals
  • Create a command palette with searchable actions
  • Support user-customizable keybindings

The pattern scales because we're composing data, not coupling objects.

Reflection

We started with a problem: keyboard shortcuts without spaghetti code.

We solved it by separating concerns:

  • CFocusable says "I can receive focus" (data)
  • CActions says "I have these shortcuts" (data)
  • ActionsPlugin says "When those exist together, wire them up" (behavior)

No component knows about the others. Add a new shortcut? Update one entity's CActions. Add a new focusable element? Add CFocusable. The plugin handles the rest.

That's the power of Entity-Component-System for UI.

Tera Template Context (Zola data-loc)
{
  "config": {
    "base_url": "/",
    "mode": "build",
    "title": "Phosphor",
    "description": "Things we learned while building Phosphor",
    "languages": {},
    "default_language": "en",
    "generate_feed": true,
    "generate_feeds": true,
    "feed_filenames": [
      "atom.xml"
    ],
    "taxonomies": [],
    "author": null,
    "build_search_index": true,
    "extra": {
      "sections": [
        "Getting Started",
        "Managing Complexity",
        "Moving Quickly",
        "Understanding Quickly",
        "Architecture Patterns",
        "Interactive Demos",
        "Developer Tools",
        "Advanced Topics"
      ],
      "author": "Phosphor",
      "drafts": true
    },
    "markdown": {
      "highlight_code": true,
      "error_on_missing_highlight": false,
      "highlight_theme": "base16-ocean-dark",
      "highlight_themes_css": [],
      "render_emoji": false,
      "external_links_class": null,
      "external_links_target_blank": false,
      "external_links_no_follow": false,
      "external_links_no_referrer": false,
      "smart_punctuation": false,
      "definition_list": false,
      "bottom_footnotes": false,
      "extra_syntaxes_and_themes": [],
      "lazy_async_image": false,
      "insert_anchor_links": "none",
      "github_alerts": false
    },
    "search": {
      "index_format": "elasticlunr_javascript"
    },
    "generate_sitemap": true,
    "generate_robots_txt": true,
    "exclude_paginated_pages_in_sitemap": "none"
  },
  "current_path": "/keyboard-navigation-demo/",
  "current_url": "/keyboard-navigation-demo/",
  "lang": "en",
  "page": {
    "relative_path": "keyboard-navigation-demo.md",
    "colocated_path": null,
    "content": "

Let's Build Composable Keyboard Navigation Together

\n
\n

Prerequisites: We'll assume you're comfortable with React and TypeScript. We'll introduce Entity-Component-System (ECS) concepts as we go - no prior game dev experience needed!

\n

Time to read: ~15 minutes
\nWhat we'll learn: How to build keyboard navigation using composable plugins that don't know about each other

\n
\n

The Problem We're Solving

\n

We're building a complex UI, and we need keyboard shortcuts everywhere. Our text editor needs Cmd+B for bold, our cards need arrow keys for navigation, our buttons need Enter to activate.

\n

We could write one giant keyboard handler that knows about every component. But we've been down that road before - it becomes a tangled mess the moment we need context-sensitive shortcuts or want to test things in isolation. Every time we add a component, we're editing that massive switch statement. Every time a shortcut conflicts, we're debugging spaghetti code.

\n

Let's try something different. We'll build a system where components declare what they need, and a plugin wires everything together automatically. No tight coupling, no spaghetti code, and every piece testable in isolation.

\n

What We're Building

\n

We'll create three interactive cards, each with different keyboard shortcuts. When we focus a card, its shortcuts become active. Press ↑/↓ to navigate between cards, then try each card's unique actions.

\n

By the end, we'll understand how four small building blocks compose into a working keyboard system - without any of them knowing about the others.

\n

Our Approach: Entities, Components, and Plugins

\n

We're borrowing a pattern from game development called Entity-Component-System (ECS):

\n
    \n
  • Entity = A unique identifier for a thing in the system, not a class or instance
  • \n
  • Component = Data attached to an entity via a component type key
  • \n
  • Plugin = Behavior that queries entities with specific component combinations and reacts to changes
  • \n
\n

The mapping to React:

\n
ReactECS\n─────────────────────────────────────\nComponent instanceEntity (just a UID)\nProps/state shapeComponent (data attached to UID)\nContext + useEffectPlugin (reactive behavior)\nuseStateAtom (Jotai reactive state)\n
\n

The key insight: Components are just data. Plugins add behavior by querying for that data. Nothing is tightly coupled.

\n

Let's see this in practice.

\n

Try It Out First

\n

Before diving into theory, let's play with what we're building:

\n\n
\n\n

Watch how the demo responds. Notice that only the focused card's shortcuts work - the others are \"dormant\" until focused.

\n

Now let's understand how we built this.

\n

Our Four Building Blocks

\n

Let's break down our keyboard system into four composable pieces.

\n

Block 1: Making Things Focusable

\n

First, we need to mark which entities can receive focus. We'll create a CFocusable component:

\n
\n \n
\n
export class CFocusable extends World.Component(\"focusable\")<\n  CFocusable,\n  {\n    handler: Handler<ExitDirection>;\n    hasDirectFocusAtom: Atom<boolean>;\n  }\n>() {\n}
\n
\n
\n

What this gives us:

\n
    \n
  • hasDirectFocusAtom: A reactive boolean that's true when this specific entity has focus
  • \n
  • handler: Called when focus enters from a direction (we'll use this later for spatial nav)
  • \n
\n

Let's use it:

\n
const buttonUID = uid(null, null, "my-button");\n\nworld.addEntity(buttonUID, ButtonEntity, {\n  focusable: CFocusable.of({\n    hasDirectFocusAtom: atom(false),\n    handler: () => handled`button focused`,\n  }),\n});\n
\n

Notice: Our component doesn't know HOW focus works, just that it CAN be focused. It's pure data.

\n

Block 2: Tracking Which Entity Has Focus

\n

We have focusable entities, but we need to track which one currently has focus. That's a singleton concern - only one entity can have focus at a time.

\n

We'll create a \"Unique\" (a singleton component):

\n
\n \n
\n
export class UCurrentFocus extends World.Unique(\"currentFocus\")<\n  UCurrentFocus,\n  { activeFocusAtom: PrimitiveAtom<UID | null> }\n>() {}
\n
\n
\n

What this gives us:

\n
    \n
  • activeFocusAtom: Holds the UID of whichever entity currently has focus (or null)
  • \n
\n

How it connects:

\n
// When we focus a card:\nconst focusUnique = world.getUniqueOrThrow(UCurrentFocus);\nworld.store.set(focusUnique.activeFocusAtom, cardUID);\n\n// Anywhere else in our app:\nconst focusedEntityUID = world.store.get(focusUnique.activeFocusAtom);\n
\n

Notice: CFocusable and UCurrentFocus don't import each other. They communicate through atoms. The CFocusable Plugin (which we'll see soon) is what wires them together.

\n

Block 3: Declaring Actions

\n

Now we need entities to declare what keyboard actions they support:

\n
\n \n
\n
export type AnyAction = {\n  label: string;\n  defaultKeybinding: DefaultKeyCombo;\n  description?: string;\n  icon?: any;\n  self?: boolean;\n  hideFromLastPressed?: boolean;\n};\ntype AnyBindables = Record<string, AnyAction>;\n\nexport type ActionEvent = { target: UID };\n\nexport namespace CActions {\n  export type Bindings<T extends AnyBindables> = {\n    [P in keyof T]: Handler<ActionEvent> | Falsey;\n  };\n}\n\nexport type ActionBindings<T extends AnyBindables = AnyBindables> = {\n  bindingSource: DevString;\n  registryKey: ActionRegistryKey<T>;\n  bindingsAtom: Atom<CActions.Bindings<T>>;\n};\n\nexport class ActionRegistryKey<T extends AnyBindables = AnyBindables> {\n  constructor(\n    public readonly key: string,\n    public readonly meta: { source: DevString; sectionName: string },\n    public readonly bindables: T,\n  ) {}\n}\n\nexport class CActions extends World.Component(\"actions\")<CActions, ActionBindings[]>() {\n  static bind<T extends AnyBindables>(key: ActionBindings<T>) {\n    return CActions.of([key as ActionBindings]);\n  }\n\n  static merge(...bindings: Array<ActionBindings | ActionBindings[]>) {\n    const out: ActionBindings[] = [];\n    for (const b of bindings) {\n      if (Array.isArray(b)) {\n        out.push(...b);\n      } else {\n        out.push(b);\n      }\n    }\n    return CActions.of(out);\n  }\n\n  static defineActions<T extends AnyBindables>(\n    key: string,\n    meta: { source: DevString; sectionName: string },\n    actions: T,\n  ): ActionRegistryKey<T> {\n    return new ActionRegistryKey(key, meta, actions);\n  }\n}
\n
\n
\n

What this gives us:

\n
    \n
  • A way to define available actions (defineActions)
  • \n
  • A way to bind handlers to those actions per entity
  • \n
  • Actions are just metadata: label, key binding, description
  • \n
\n

Let's define some actions for our cards:

\n
const CardActions = CActions.defineActions(\n  "card-actions",\n  { source: dev`Card actions`, sectionName: "Card Actions" },\n  {\n    delete: {\n      label: "Delete Card",\n      defaultKeybinding: "X" as const,\n      description: "Remove this card",\n    },\n    edit: {\n      label: "Edit Card",\n      defaultKeybinding: "E" as const,\n      description: "Edit card content",\n    },\n  },\n);\n
\n

Now attach handlers to a specific card entity:

\n
world.addEntity(cardUID, CardEntity, {\n  actions: CActions.bind({\n    bindingSource: dev`Card 1 actions`,\n    registryKey: CardActions,\n    bindingsAtom: atom({\n      delete: () => {\n        alert("Deleted!");\n        return handled`delete`;\n      },\n      edit: () => {\n        alert("Editing!");\n        return handled`edit`;\n      },\n    }),\n  }),\n});\n
\n

Notice: We defined the action schema once, then bound different handlers per entity. One card might delete, another might archive. Same action definition, different behavior.

\n

Block 4: Wiring It All Together - ActionsPlugin

\n

Here's where the magic happens. We need something that:

\n
    \n
  1. Listens for keyboard events
  2. \n
  3. Finds the currently focused entity
  4. \n
  5. Matches keys to actions
  6. \n
  7. Executes the handler
  8. \n
\n

That's what our ActionsPlugin does:

\n
\n \n
\n
export const ActionsPlugin = World.definePlugin({\n  name: dev`ActionsPlugin`,\n  setup: (build) => {\n    const { store } = build;\n\n    // Track the currently focused entity\n    const currentFocusAtom = atom((get) =>\n      pipeNonNull(get(build.getUniqueAtom(UCurrentFocus)), (a) => get(a.activeFocusAtom)),\n    );\n\n    const rootUIDAtom = atom<UID | null>(null);\n    const currentDispatchSpotAtom = atom((get) => get(currentFocusAtom) ?? get(rootUIDAtom));\n\n    const handleOnce = new WeakSet<KeyboardEvent>();\n    build.addUnique(UKeydownRootHandler, {\n      handler(reason, keyboardEvent) {\n        if (handleOnce.has(keyboardEvent)) return Outcome.Passthrough;\n        handleOnce.add(keyboardEvent);\n        if (keyboardEvent.defaultPrevented) return Outcome.Passthrough;\n        const world = store.get(build.worldAtom);\n        if (!world) return Outcome.Passthrough;\n        const dispatchFromUID = store.get(currentDispatchSpotAtom);\n        if (!dispatchFromUID) return Outcome.Passthrough;\n        // Walk up the parent chain looking for keydown handlers\n        const result = CParent.dispatch(\n          dev`keydown from root`.because(reason),\n          world,\n          dispatchFromUID,\n          CKeydownHandler,\n          reason,\n          keyboardEvent,\n        );\n        // Prevent default browser behavior when we handle the key\n        if (result !== Outcome.Passthrough) {\n          keyboardEvent.preventDefault();\n        }\n        return result;\n      },\n      rootUIDAtom,\n    });
\n
\n
\n

Here's what happens when we press a key:

\n
User presses "X"\n  ↓\nActionsPlugin.UKeydownRootHandler receives event\n  ↓\nQuery: Which entity has focus? (from UCurrentFocus)\n  ↓\nWalk up parent chain: Does this entity have CKeydownHandler?\n  ↓\nMatch key "X" to action "delete"\n  ↓\nCall the handler we bound earlier\n  ↓\npreventDefault() so browser doesn't scroll\n
\n

The beautiful part? None of these components import each other. The plugin queries the world: \"Give me the focused entity. Does it have CActions? Great, wire up keyboard handling for it.\"

\n
\n \n
\n
    // Provide keydown handler for entities with CActions\n    build.onEntityCreated(\n      {\n        requires: [CActions],\n        provides: [CKeydownHandler],\n      },\n      (uid, { actions }) => {\n        const combinedCombosAtom = atom((get) => {\n          type ComboData = {\n            actionKey: string;\n            handler: (reason: DevString, event: ActionEvent) => Outcome;\n          } & AnyAction;\n\n          const combosMap = new Map<string, ComboData[]>();\n\n          for (const actionSet of actions) {\n            const resolvedBindings = get(actionSet.bindingsAtom);\n            for (const [actionKey, maybeHandler] of Object.entries(resolvedBindings)) {\n              if (!maybeHandler) continue;\n              const bindable = actionSet.registryKey.bindables[actionKey];\n              if (!bindable) continue;\n              const defaultKey = bindable.defaultKeybinding;\n              const combo = normalizedKeyCombo(defaultKey, ENV_KEYBOARD_KIND).normalized;\n              const comboData: ComboData = {\n                actionKey,\n                handler: maybeHandler,\n                ...bindable,\n              };\n              const list = combosMap.get(combo);\n              if (!list) {\n                combosMap.set(combo, [comboData]);\n              } else {\n                list.push(comboData);\n              }\n            }\n          }\n          return combosMap;\n        });\n\n        const keydownHandler = CKeydownHandler.of({\n          handler(reason, event) {\n            // Omit shift for letter keys so \"Shift+X\" matches \"X\"\n            const combos = addModifiersToKeyCombo(ENV_KEYBOARD_KIND, event, true);\n            if (event.defaultPrevented) return Outcome.Passthrough;\n            const combosMap = store.get(combinedCombosAtom);\n\n            for (const combo of combos) {\n              const comboDatas = combosMap.get(combo.normalized);\n              if (!comboDatas) continue;\n              for (const comboData of comboDatas) {\n                const outcome = comboData.handler(dev`Key combo pressed: ${combo.normalized}`.because(reason), {\n                  target: uid,\n                });\n                if (outcome !== Outcome.Passthrough) {\n                  return outcome;\n                }\n              }\n            }\n            return Outcome.Passthrough;\n          },\n        });\n\n        return { keydownHandler };\n      },\n    );
\n
\n
\n

This is the actual per-entity handler creation. When we add an entity with CActions, the plugin automatically:

\n
    \n
  • Reads all action definitions
  • \n
  • Normalizes key combos (so \"X\" and \"Shift-X\" both match)
  • \n
  • Creates a CKeydownHandler that matches keys to handlers
  • \n
  • Plugs it into the event system
  • \n
\n

We don't call any of this ourselves. It Just Works™.

\n

What We Learned

\n

Let's step back and appreciate what we built:

\n

✅ We Can Test Everything In Isolation

\n

Want to test if \"X\" triggers delete? No React needed:

\n
const world = createTestWorld();\nconst cardUID = addCardEntity(world, {\n  onDelete: mockFn,\n});\n\n// Simulate focus\nworld.store.set(focusAtom, cardUID);\n\n// Simulate keypress\nrootHandler.handler(dev`test`, { key: "x" });\n\nexpect(mockFn).toHaveBeenCalled();\n
\n

✅ Components Are Composable

\n

A simple button might only have CFocusable. A rich text editor adds CActions with 50 shortcuts. A card adds both plus CSelectable. Mix and match:

\n
// Simple button\nworld.addEntity(uid, SimpleButton, {\n  focusable: CFocusable.of({...}),\n});\n\n// Rich editor\nworld.addEntity(uid, RichEditor, {\n  focusable: CFocusable.of({...}),\n  actions: CActions.merge(\n    TextFormattingActions,    // Bold, italic, etc.\n    BlockActions,             // Lists, headings\n    ClipboardActions,         // Cut, copy, paste\n  ),\n});\n
\n

✅ Everything Is Observable

\n

All state lives in Jotai atoms. DevTools can show us:

\n
    \n
  • Which entity has focus right now
  • \n
  • What actions are available
  • \n
  • When each action was last pressed
  • \n
\n

✅ Nothing Is Tightly Coupled

\n

Look at what we didn't do:

\n
    \n
  • ❌ Import KeyboardManager in every component
  • \n
  • ❌ Call registerShortcut() imperatively
  • \n
  • ❌ Have components know about each other
  • \n
  • ❌ Write glue code to connect pieces
  • \n
\n

Instead:

\n
    \n
  • ✅ Components declare data (\"I can be focused\", \"I have actions\")
  • \n
  • ✅ Plugins react to component presence (\"Oh, you have both? Let me wire you up\")
  • \n
  • ✅ Everything communicates through atoms
  • \n
  • ✅ Adding a new entity requires zero changes to existing code
  • \n
\n

When Should We Use This Approach?

\n

Let's be honest about trade-offs.

\n

This pattern shines when:

\n
    \n
  • ✅ We're already using ECS architecture in our app (like we do in Phosphor)
  • \n
  • ✅ We have complex nesting and need context-sensitive shortcuts
  • \n
  • ✅ We want every piece testable in isolation
  • \n
  • ✅ Our app has 10+ different shortcut contexts
  • \n
  • ✅ We value composition over simplicity
  • \n
\n

Consider simpler alternatives when:

\n
    \n
  • ❌ We're building a small app with <20 shortcuts
  • \n
  • ❌ We want minimal bundle size (this adds ~30KB with Jotai + ECS)
  • \n
  • ❌ Our team isn't familiar with reactive state patterns
  • \n
  • ❌ We just need basic \"hotkey → function\" mapping
  • \n
\n

Simpler Alternatives We Considered

\n\n\n\n\n\n
ApproachBundle SizeLearning CurveTest IsolationContext-Sensitive
ECS (Ours)~30KBHighExcellentExcellent
tinykeys2KBLowGoodManual
React Context0KBMediumMediumGood
Mousetrap8KBLowPoorManual
\n

For our use case (complex editor with nested contexts), the composition benefits outweigh the complexity cost. For most apps, a 2KB library like tinykeys is probably the right call.

\n

Tracing a Keypress Together

\n

Let's walk through exactly what happens when we press \"X\" to delete a card. This demystifies the \"magic\":

\n
📍 Step 1: DOM Event (keyboard-demo.entrypoint.tsx:29)\n   document.addEventListener('keydown', ...)\n   Event fires with event.key = "x"\n\n   ↓\n\n📍 Step 2: Root Handler (ActionsPlugin.ts:33-42)\n   UKeydownRootHandler.handler() receives event\n   Check currentDispatchSpotAtom: Is anything focused?\n   Result: Card 2 has focus\n\n   ↓\n\n📍 Step 3: Parent Walk (ActionsPlugin.ts:44-55)\n   CParent.dispatch() walks up the entity tree\n   Current: Card 2 → Does it have CKeydownHandler? YES ✓\n\n   ↓\n\n📍 Step 4: Key Normalization (ActionsPlugin.ts:105-107)\n   addModifiersToKeyCombo("x", event, omitShift=true)\n   "x" → "X" (uppercase)\n   No modifiers, final combo: "X"\n\n   ↓\n\n📍 Step 5: Action Lookup (ActionsPlugin.ts:110-115)\n   combosMap.get("X") → [{action: "delete", handler: fn}]\n   Call handler(dev`Key combo`, {target: card2UID})\n\n   ↓\n\n📍 Step 6: Our Handler (createKeyboardDemo.ts:83)\n   onDelete() runs\n   alert("Deleted Card 2!")\n   Returns: handled`delete`\n\n   ↓\n\n📍 Step 7: Prevent Default (ActionsPlugin.ts:50-52)\n   outcome !== Passthrough, so:\n   event.preventDefault() ← stops browser scroll\n   Return "handled" to stop propagation\n
\n

Key insight: Notice how information flows through atoms and component queries, never through direct imports or method calls. That's the decoupling in action.

\n

What's Next?

\n

Now that we understand composable keyboard navigation, we can:

\n
    \n
  • Add spatial navigation (arrow keys navigate a 2D grid)
  • \n
  • Build focus trapping for modals
  • \n
  • Create a command palette with searchable actions
  • \n
  • Support user-customizable keybindings
  • \n
\n

The pattern scales because we're composing data, not coupling objects.

\n

Reflection

\n

We started with a problem: keyboard shortcuts without spaghetti code.

\n

We solved it by separating concerns:

\n
    \n
  • CFocusable says \"I can receive focus\" (data)
  • \n
  • CActions says \"I have these shortcuts\" (data)
  • \n
  • ActionsPlugin says \"When those exist together, wire them up\" (behavior)
  • \n
\n

No component knows about the others. Add a new shortcut? Update one entity's CActions. Add a new focusable element? Add CFocusable. The plugin handles the rest.

\n

That's the power of Entity-Component-System for UI.

\n", "permalink": "/keyboard-navigation-demo/", "slug": "keyboard-navigation-demo", "ancestors": [ "_index.md" ], "title": "Let's Build Composable Keyboard Navigation Together", "description": null, "updated": null, "date": "2025-10-20", "year": 2025, "month": 10, "day": 20, "taxonomies": {}, "authors": [], "extra": { "nav_section": "Interactive Demos", "nav_order": 1 }, "path": "/keyboard-navigation-demo/", "components": [ "keyboard-navigation-demo" ], "summary": null, "toc": [ { "level": 1, "id": "let-s-build-composable-keyboard-navigation-together", "permalink": "/keyboard-navigation-demo/#let-s-build-composable-keyboard-navigation-together", "title": "Let's Build Composable Keyboard Navigation Together ⋅", "children": [ { "level": 2, "id": "the-problem-we-re-solving", "permalink": "/keyboard-navigation-demo/#the-problem-we-re-solving", "title": "The Problem We're Solving ⋅", "children": [] }, { "level": 2, "id": "what-we-re-building", "permalink": "/keyboard-navigation-demo/#what-we-re-building", "title": "What We're Building ⋅", "children": [] }, { "level": 2, "id": "our-approach-entities-components-and-plugins", "permalink": "/keyboard-navigation-demo/#our-approach-entities-components-and-plugins", "title": "Our Approach: Entities, Components, and Plugins ⋅", "children": [] }, { "level": 2, "id": "try-it-out-first", "permalink": "/keyboard-navigation-demo/#try-it-out-first", "title": "Try It Out First ⋅", "children": [] }, { "level": 2, "id": "our-four-building-blocks", "permalink": "/keyboard-navigation-demo/#our-four-building-blocks", "title": "Our Four Building Blocks ⋅", "children": [ { "level": 3, "id": "block-1-making-things-focusable", "permalink": "/keyboard-navigation-demo/#block-1-making-things-focusable", "title": "Block 1: Making Things Focusable ⋅", "children": [] }, { "level": 3, "id": "block-2-tracking-which-entity-has-focus", "permalink": "/keyboard-navigation-demo/#block-2-tracking-which-entity-has-focus", "title": "Block 2: Tracking Which Entity Has Focus ⋅", "children": [] }, { "level": 3, "id": "block-3-declaring-actions", "permalink": "/keyboard-navigation-demo/#block-3-declaring-actions", "title": "Block 3: Declaring Actions ⋅", "children": [] }, { "level": 3, "id": "block-4-wiring-it-all-together-actionsplugin", "permalink": "/keyboard-navigation-demo/#block-4-wiring-it-all-together-actionsplugin", "title": "Block 4: Wiring It All Together - ActionsPlugin ⋅", "children": [] } ] }, { "level": 2, "id": "what-we-learned", "permalink": "/keyboard-navigation-demo/#what-we-learned", "title": "What We Learned ⋅", "children": [ { "level": 3, "id": "white-check-mark-we-can-test-everything-in-isolation", "permalink": "/keyboard-navigation-demo/#white-check-mark-we-can-test-everything-in-isolation", "title": "✅ We Can Test Everything In Isolation ⋅", "children": [] }, { "level": 3, "id": "white-check-mark-components-are-composable", "permalink": "/keyboard-navigation-demo/#white-check-mark-components-are-composable", "title": "✅ Components Are Composable ⋅", "children": [] }, { "level": 3, "id": "white-check-mark-everything-is-observable", "permalink": "/keyboard-navigation-demo/#white-check-mark-everything-is-observable", "title": "✅ Everything Is Observable ⋅", "children": [] }, { "level": 3, "id": "white-check-mark-nothing-is-tightly-coupled", "permalink": "/keyboard-navigation-demo/#white-check-mark-nothing-is-tightly-coupled", "title": "✅ Nothing Is Tightly Coupled ⋅", "children": [] } ] }, { "level": 2, "id": "when-should-we-use-this-approach", "permalink": "/keyboard-navigation-demo/#when-should-we-use-this-approach", "title": "When Should We Use This Approach? ⋅", "children": [ { "level": 3, "id": "simpler-alternatives-we-considered", "permalink": "/keyboard-navigation-demo/#simpler-alternatives-we-considered", "title": "Simpler Alternatives We Considered ⋅", "children": [] } ] }, { "level": 2, "id": "tracing-a-keypress-together", "permalink": "/keyboard-navigation-demo/#tracing-a-keypress-together", "title": "Tracing a Keypress Together ⋅", "children": [] }, { "level": 2, "id": "what-s-next", "permalink": "/keyboard-navigation-demo/#what-s-next", "title": "What's Next? ⋅", "children": [] }, { "level": 2, "id": "reflection", "permalink": "/keyboard-navigation-demo/#reflection", "title": "Reflection ⋅", "children": [] } ] } ], "word_count": 1559, "reading_time": 8, "assets": [], "draft": true, "lang": "en", "lower": { "relative_path": "incantations.md", "colocated_path": null, "content": "

Writing Process - Phosphor Engineering

\n

Macro Expansion (Pass 1)

\n

Context: You are expanding section bullets from bones.md into full prose for draft.md.

\n

Inputs: anchors.md (locked context) + bones.md section + current section ID

\n

Output: Expanded prose for the specified [S#] section with natural transitions

\n

Requirements:

\n
    \n
  • Follow anchors.md constraints exactly
  • \n
  • Maintain technical depth specified in anchors
  • \n
  • Add smooth transitions between bullet points
  • \n
  • Include concrete examples where bones mention them
  • \n
  • Keep section IDs [S#] in draft.md headers
  • \n
  • No inline tags yet (that's Pass 2)
  • \n
\n

Style: Technical advisor voice per be-a-technical-advisor.md

\n
\n

Micro Edit (Pass 3)

\n

Context: You are resolving inline annotation tags from draft.md via unified diff

\n

Inputs: anchors.md + draft.md section with inline tags + specific tags to resolve

\n

Output: Unified diff showing exact changes to resolve tags

\n

Requirements:

\n
    \n
  • Address each tagged issue precisely
  • \n
  • Follow tag modifiers ([!] = must-fix, [~] = nice-to-have, [->suggestion])
  • \n
  • Maintain section structure and flow
  • \n
  • Don't rewrite untagged content
  • \n
  • Verify claims with concrete evidence
  • \n
  • Add examples where [ex] tags appear
  • \n
\n
\n

Quality Check (Pass 4)

\n

Context: Final audit before publish

\n

Checklist:

\n
    \n
  • \nAll [!] tags resolved
  • \n
  • \nEvidence ratio met (per anchors.md)
  • \n
  • \nInteractive demos working
  • \n
  • \nForbidden phrases removed
  • \n
  • \nSuccess criteria achieved
  • \n
  • \nHook visceral, conclusion actionable
  • \n
\n", "permalink": "/incantations/", "slug": "incantations", "ancestors": [ "_index.md" ], "title": "Incantations", "description": null, "updated": null, "date": "2025-10-20", "year": 2025, "month": 10, "day": 20, "taxonomies": {}, "authors": [], "extra": { "nav_section": "Advanced Topics", "nav_order": 1 }, "path": "/incantations/", "components": [ "incantations" ], "summary": null, "toc": [ { "level": 1, "id": "writing-process-phosphor-engineering", "permalink": "/incantations/#writing-process-phosphor-engineering", "title": "Writing Process - Phosphor Engineering ⋅", "children": [ { "level": 2, "id": "macro-expansion-pass-1", "permalink": "/incantations/#macro-expansion-pass-1", "title": "Macro Expansion (Pass 1) ⋅", "children": [] }, { "level": 2, "id": "micro-edit-pass-3", "permalink": "/incantations/#micro-edit-pass-3", "title": "Micro Edit (Pass 3) ⋅", "children": [] }, { "level": 2, "id": "quality-check-pass-4", "permalink": "/incantations/#quality-check-pass-4", "title": "Quality Check (Pass 4) ⋅", "children": [] } ] } ], "word_count": 230, "reading_time": 2, "assets": [], "draft": true, "lang": "en", "lower": null, "higher": null, "translations": [], "backlinks": [] }, "higher": { "relative_path": "view-model-interfaces.md", "colocated_path": null, "content": "

View Model Interfaces

\n\n\n
\n \n Raw\n \n \n
View Model Interfaces are a pattern for designing the boundary between view and business logic. I have leveraged, to my benefit, this set of rules to follow in several projects.\n\n- Where either the performance needed to be very high\n- By performance I mean reactivity like needs to be very controlled\n- The complexity that comes from raw React hooks was difficult to follow\n- When I've had to manage complex UI state that might involve CSS animations or user inputs and the like where I really need to understand and know exactly what's going to change in the DOM to not interrupt the user who might be selecting text or inserting inputs and so forth.\n I accomplished a lot of. There's also a fourth thing which is also equally important is being able when a certain part of my application becomes sufficiently complicated enough I have found that adhering to these rules for that boundary leads to much easier testability, much easier prototyping, and a simpler UI.\n\nThe controversial thesis here is that you should take the time to split out. You should take the time to adopt a reactivity toolset that is not dependent on the UI or rendering contexts. You should use fine-grained reactivity tooling (whether it's signals or observables or the like) so that when you are defining the interface, it's extremely clear from a type system point of view what's going to change and where to draw your boundaries.\n\nThe audience here is somewhat familiar with different patterns of encapsulating UI. They probably have a lot of experience with React, Svelte, or Vue. I'm going to be focusing on React.\n\nThe too-long-didn't-read, it's that... Your view model interface should be entirely interactive outside of the view's lifecycle, however your view lifecycle works (whether that's React hook context) you shouldn't have to rely on that when designing your view model interface and following these patterns will lead to more easily testable code, more easily iterated on code, and better LLM performance for complex code, complex UIs.\n\nThe reason that the LLMs perform better is because you can define a clear boundary between \"here's what the state looks like and how it is reactive in a type-driven way\" and have an AI implement the UI above that. You'll know that the AI's implementation of that UI is type-checked and following your plan.\n\nThese view model interfaces also provide a central point of documentation for what's expected out of the view and what the different sections of a UI are, so it gives you an opportunity to inform an AI how the view is supposed to be laid out before it gets started and the AI doesn't get distracted by the actual implementation.\n\nMy personal hook here has been, I just tend to struggle with managing to hold in my head all of events management plus styling plus render performance plus contexts plus testing concerns. Like early on in my career, I realized I just struggled a lot as the normal React applications got more complex and I found myself in a lot of conversations about hooks and how to manage hooks and how to manage the reactivity of a certain part of an application. I always could just step back and say, \"What the fuck is actually going on?\" Like I think I know and I can kinda look at a few things at the same time to get an idea, but at the end of the day it was really unclear. Once you had a couple of use effects involved and some use states and maybe a context, I really lost the feeling that I could understand what was happening.\n\nHook: As my software projects and jobs became targeting more complicated UIs and authorable UIs, I found a video about Flutter BLOC pattern from Paulo Soares in 2018 that described this kind of thinking pattern to apply. From then on, I have been leveraging this kind of pattern. Originally, I actually called it MVVM or the block pattern, but the more that I've implemented it and tried to explain it to people, it became more obvious that it's the important part of the pattern is actually just a view model interface and having a definition of what a view model interface is. So that's what we're going to talk about in this article.\n\nSo yeah, the context here is that you're trying to reason about hooks and how they're interacting together and you're trying to tie things together and you're also trying to communicate to other engineers how something works and you're managing. contexts and I mean you don't have that kind of memory to remember like spatially how all these contexts like work together. You probably really enjoyed the container and display components patterns and react early on and you were wondering what the next iteration of those ideas might look like.\n\nThe turn here is we define everything that the UI should look like, the actual even structure of the UI, we define that in a view model, like a type and an interface. That interface is going to expose as little as closely to how the UI gets mapped as possible, so that when we go to render the UI, you shouldn't have to reach for anything beyond a dot map, and you shouldn't have to call any functions with anything beyond, like, input text. If there's an event that changes the, your goal is to basically make all of the inner workings of your business logic, fetch state, everything opaque to the UI code.\n\nBy following this rule, we can actually forego testing the majority of our UI side and skip a lot of complexity by not needing to set up end-to-end testing from React all the way to our business logic. We can actually test our entire application interacting directly with a view model which is much simpler than interacting with the UI directly or trying to only interact with the state machine itself.\n\nProof: In fact, for most of my projects now, I only test when the only testing I do up front is on the view model itself, and usually that's only if the view has sufficiently complicated logic. I found that by breaking apart the view from the actual business logic, there's a lot fewer things that you need to test or that can go wrong because it's a lot easier to reason about.
\n
\n\n\n\n
\n \n Anchors\n \n \n
TODO: translate the raw thoughts into initial anchors.\nControversial thesis: You should split view from business logic with a type-first, fine-grained reactive View Model Interface that lives outside the view lifecycle.\n\n- This makes complex UIs testable, predictable, and easier to co-develop (including with LLMs).\n\nHero visual ideas:\n\n- Running UI, View Model interface, and test cases in three panes\n- Could be a simple text input with autocomplete/hints at the bottom
\n
\n\n

Introduction

\n

View Model Interfaces are a pattern for designing the boundary between view and business logic.

\n

Let's take a look at a simple example of what a view model interface for the following autocomplete input might look like.

\n\n
\n
\n
\n
\n \n

\n Interface\n

\n \n
.codeblock]:!m-0 [&>.codeblock]:!rounded-none\"\n >\n
\n \n
\n
export interface AutocompleteVM {\n  inputText$: Reactive<string>;\n  updateInput(text: string, pos: \"end\" | number): void;\n  /** Creating a selection should dismiss */\n  updateSelection(pos: number, anchor?: number): void;\n  pressKey(key: \"enter\" | \"up\" | \"down\" | \"escape\"): void;\n  options$: Reactive<\n    Array<{\n      key: string;\n      label: string;\n      selected$: Reactive<boolean>;\n      click(): void;\n    }>\n  >;\n}
\n
\n
\n
\n
\n\n\n\n
\n \n

\n Test\n

\n \n
.codeblock]:!m-0 [&>.codeblock]:!rounded-none\"\n >\n
\n \n
\n
  autocomplete.updateInput(\"Update @ind\", \"end\");\n  const options = get(autocomplete.options$);\n  expect(options).toMatchObject([\n    { label: \"src/index.html\" },\n    { label: \"src/index.ts\" },\n    { label: \"src/utils/index.ts\" },\n  ]);\n  expect(get(options[0].selected$)).toBe(true);\n  expect(get(options[1].selected$)).toBe(false);\n  autocomplete.pressKey(\"down\");\n  expect(get(options[0].selected$)).toBe(false);\n  expect(get(options[1].selected$)).toBe(true);
\n
\n
\n\n\n
\nFrom the interface alone, you should be able to picture what this is and how it behaves. Picture it in your head before you take a look at a demo of what this might look like.\n\n
\n\n
\n
\n
\n
\n
\n

Benefits

\n
    \n
  • Testability
  • \n
  • Separation of concerns
  • \n
  • Simplified UI
  • \n
\n

Example

\n\n
\n
\n
\n
\n \n
.codeblock]:!m-0 [&>.codeblock]:!rounded-none\"\n >\n
\n\n\n
\n \n
\n
export type TodoItemVM = {\n  key: string;\n  text$: LiveQuery<string>; \n  completed$: LiveQuery<boolean>; \n  toggleCompleted: () => void;\n  remove: () => void;\n};\n\nexport type TodoListVM = {\n  header: {\n    newTodoText$: LiveQuery<string>; \n    updateNewTodoText: (text: string) => void;\n    addTodo: () => void;\n  };\n  itemList: {\n    items$: LiveQuery<TodoItemVM[]>; \n  };\n  footer: {\n    incompleteCount$: LiveQuery<number>; \n    currentFilter$: LiveQuery<\"all\" | \"active\" | \"completed\">; \n    showAll: () => void;\n    showActive: () => void;\n    showCompleted: () => void;\n    clearCompleted: () => void;\n  };\n};
\n
\n
\n
\n
\n
\n
\n

Defining a Scope

\n
\n \n
\n
const createID = (name: string) => `${name}_${nanoid(12)}`; \nexport function createTodoListScope(store: Store): TodoListVM {\n  const currentFilter$ = computed(\n    (query) => query(uiState$).filter,\n    { label: \"filter\" }, \n  );\n  const newTodoText$ = computed(\n    (query) => query(uiState$).newTodoText,\n    { label: \"newTodoText\" }, \n  );\n  const createTodoItemVM = memoFn((id: string): TodoItemVM => {\n    const completed$ = queryDb(\n      () =>\n        tables.todos.select(\"completed\").where({ id }).first({\n          behaviour: \"error\",\n        }),\n      { label: \"todoItem.completed\", deps: [id] }, \n    );\n    return {\n      key: id,\n      completed$,\n      text$: queryDb(\n        () =>\n          tables.todos.select(\"text\").where({ id }).first({\n            behaviour: \"error\",\n          }),\n        { label: \"todoItem.text\", deps: [id] }, \n      ),\n      toggleCompleted: () =>\n        store.commit(store.query(completed$) ? events.todoUncompleted({ id }) : events.todoCompleted({ id })),\n      remove: () => store.commit(events.todoDeleted({ id, deletedAt: new Date() })),\n    };\n  });\n  const visibleTodosQuery = (filter: \"all\" | \"active\" | \"completed\") =>\n    queryDb(\n      () =>\n        tables.todos.where({\n          completed: filter === \"all\" ? undefined : filter === \"completed\",\n          deletedAt: { op: \"=\", value: null },\n        }),\n      {\n        label: \"visibleTodos\", \n        map: (rows) => rows.map((row) => createTodoItemVM(row.id)),\n        deps: [filter],\n      },\n    );\n  const visibleTodos$ = computed(\n    (query) => {\n      const filter = query(currentFilter$);\n      return query(visibleTodosQuery(filter));\n    },\n    { label: \"visibleTodos\" }, \n  );\n\n  const incompleteCount$ = queryDb(\n    tables.todos.count().where({ completed: false, deletedAt: null }),\n    { label: \"incompleteCount\" }, \n  );\n\n\n  return {\n    header: {\n      newTodoText$,\n      updateNewTodoText: (text: string) => store.commit(events.uiStateSet({ newTodoText: text })),\n      addTodo: () => {\n        const newTodoText = store.query(newTodoText$).trim();\n        if (newTodoText) {\n          store.commit(\n            events.todoCreated({ id: createID(\"todo\"), text: newTodoText }),\n            events.uiStateSet({ newTodoText: \"\" }), // update text\n          );\n        }\n      },\n    },\n    itemList: {\n      items$: visibleTodos$,\n    },\n    footer: {\n      incompleteCount$,\n      currentFilter$,\n      showAll: () => store.commit(events.uiStateSet({ filter: \"all\" })),\n      showActive: () => store.commit(events.uiStateSet({ filter: \"active\" })),\n      showCompleted: () => store.commit(events.uiStateSet({ filter: \"completed\" })),\n      clearCompleted: () => store.commit(events.todoClearedCompleted({ deletedAt: new Date() })),\n    },\n  };\n}
\n
\n
\n", "permalink": "/view-model-interfaces/", "slug": "view-model-interfaces", "ancestors": [ "_index.md" ], "title": "View Model Interfaces", "description": "Designing the boundary between view and business logic.", "updated": null, "date": "2025-10-17", "year": 2025, "month": 10, "day": 17, "taxonomies": {}, "authors": [], "extra": { "category": "engineering", "nav_section": "Managing Complexity", "nav_order": 1 }, "path": "/view-model-interfaces/", "components": [ "view-model-interfaces" ], "summary": null, "toc": [ { "level": 1, "id": "view-model-interfaces", "permalink": "/view-model-interfaces/#view-model-interfaces", "title": "View Model Interfaces ⋅", "children": [ { "level": 2, "id": "introduction", "permalink": "/view-model-interfaces/#introduction", "title": "Introduction ⋅", "children": [] }, { "level": 2, "id": "benefits", "permalink": "/view-model-interfaces/#benefits", "title": "Benefits ⋅", "children": [] } ] }, { "level": 1, "id": "example", "permalink": "/view-model-interfaces/#example", "title": "Example ⋅", "children": [ { "level": 2, "id": "defining-a-scope", "permalink": "/view-model-interfaces/#defining-a-scope", "title": "Defining a Scope ⋅", "children": [] } ] } ], "word_count": 1515, "reading_time": 8, "assets": [], "draft": true, "lang": "en", "lower": null, "higher": null, "translations": [], "backlinks": [] }, "translations": [], "backlinks": [] }, "zola_version": "0.21.0" }