Events and subscribe

Typed semantic coordination between actors, without reaching through to write another actor's state.

Events are how an actor says what happened without knowing who cares. subscribe is how another actor decides to care.

type ButtonEvents = {
  pressed: { source: "keyboard" | "pointer" };
};

class Button extends Actor<never, ButtonEvents> {
  *press(source: ButtonEvents["pressed"]["source"]) {
    yield* this.emit.pressed({ source });
  }
}

subscribe(this, button, {
  *pressed(event) {
    yield* this.submit(event.source);
  },
});

The event map is the contract, and it is declared exactly once: as the second type parameter of Actor (slots come first — pass never when the actor hosts nothing). this.emit is typed from it, and subscribe infers the same map from the source's emit, so payloads are typed at both ends and a handler cannot read a field the emitter does not send. There is no runtime registration — an event exists because the type says so.

Use them for

  • Semantic intent between actors — pressed, keyPressed, windowClosed.
  • Coordination that must not become a reach-through write.
  • Anything a composition should be able to re-route without editing either actor.

Why not just write the other actor's state

Because the owner's invariants stop holding. An actor that owns windows knows that opening one implies focusing it and moving it to front; an outsider writing windows.set(...) knows only the array.

// Wrong — the caller now owns invariants it cannot see.
dock.onClick(() => desktop.windows.set(withWindowOpened(desktop.windows(), id)));

// Right — the owner responds to intent.
subscribe(desktop, dock, {
  *itemActivated({ appId }) {
    yield* this.openWindow(`${appId}-window`);
  },
});

Composition-level routing

Because handlers are attached rather than baked in, the same actor behaves differently in different compositions. The audio system is built entirely on this — actors emit semantic events and stay sound-agnostic, and the composition declares which events make noise:

audio.bindSounds(keyboard, sounds, {
  keyPressed: ({ keyId }) => getKeyboardSoundCue(keyId),
});

See Audio and sound effects.

Do not use them for

Instead ofUse
Actor-local statesignal
Computed valuesderived
Local reactive responsewatch

On this page