01 Event Subscriptions - christoph-fricke/xsystem GitHub Wiki

The actor definition in XState enables actors to subscribe to emitted state of other actors via the subscribe method. This is commonly used to subscribe UI components to state updates of an actor such as an interpreted state machines. XSystem moves beyond state machines, which define an behavior, and focuses on the level of actor systems, i.e. multiple spawned behaviors that communicate with one another.

In actor systems, the different actors communicate by sending events to each other. We can identify two concepts for sending events. The fundamental concept that already exists in XState is sending an event to a specific actor. However, sometimes a use-case requires sending an event to multiple, unknown actors. This is commonly referred to as publishing an event to subscribers and is known as the publish/subscribe (pub/sub) pattern. XSystem focuses on providing support with great TypeScript inference for this pattern in XState-based actor systems.

Subscribing to Events

To enable pub/sub, a mechanism is provided that allows other actors to subscribe to published events of an actors. This mechanism is purely based on events and is fully compatible with XState, while achieving great TypeScript inference.

The event that can be published by an actor are different from the events that can be send to the actor.

Any actor that is able to publish events can receive two XSystem-defined events:

  • SubscribeEvent: Subscribes an actor reference to published events. Optionally, a event match can be included, which is used to subscribe an actor to only the specified events.
  • UnsubscribeEvent: Unsubscribes an actor reference from all published events.

The following examples highlights how subscribe und unsubscribe events can be send to an actor that supports publishing events. Both publisher and subscriber are mocked for simplicity.

import { ActorRef } from "xstate";
import { SubEvent, subscribe, unsubscribe } from "xsystem";

type PublishEvent = { type: "hello" } | { type: "world"; payload: number };

// `SubEvent` contains both Subscribe and UnsubscribeEvents and denotes that the
// actor is able to publish events of type `PublishEvent`.
const publisher = {} as ActorRef<SubEvent<PublishEvent>, null>;
const subscriber = {} as ActorRef<{ type: "world"; payload: number }, null>;

// Subscribes the subscriber to all PublishEvents.
publisher.send(subscribe(subscriber));

// Alternatively, subscribe to specific events. The available event types are
// inferred from PublishEvent and are fully typed.
publisher.send(subscribe(subscriber, ["world"]));

// Unsubscribe from all PublishEvents.
publisher.send(unsubscribe(subscriber));

HINT: Use the withSubscriptions higher-order behavior to automatically subscribe a spawned behavior to a publisher for the lifecycle of the spawned behavior.

Wildcards

Subscribing to many individual events can become quite tedious quickly. XSystem simplifies this by supporting wildcards throughout the package, which include a whole group of events. They are fully supported by event subscriptions in the event match. To remain readable and to provide full TypeScript inference, they are a bit more restrictive than Regex or matching any substring.

Wildcards work by dividing events into different scopes (or topics), which are part of the event type string. For example, user.click, user.input.type, and user.input.focus are events that are all part of the scope user. The wildcards user.* and * would match all events. The wildcard user.input.* would only match the last two events with the more specific scope user.input. A wildcard is created by appending an asterix after a scope section.

This feature relies on the convention that events are separated into scopes with dots in the event type. A single convention has to be used to enable full TypeScript inference. This usage of a dot is inspired by XState's internal events, which use dots as well.

XSystem does not make further assumptions about using lowercase or uppercase characters for other the character, but the usage of "snake_case" with dot scopes, e.g. user_event.click, is suggested and used throughout the documentation. Depending on how the community evolves, this convention might change to another character for the separator. For example, Redux suggests the usage of "/".

To summarize, event can be scoped by using dots in the event type. A wildcard applies to a scope by appending an asterix after a scope section.

Wildcard examples

  • commit is matched by * and commit
  • user.commit is matched by *, user.*, and user.commit
  • user.profile.commit is matched by *, user.*, user.profile.*, and user.profile.commit
  • ...

Publishing Events

The previous sections covered how subscribers can subscribe to publishers. Let us take a look at how actors are able to publish events.

Essentially, a publisher has to keep track of all subscribers and has to manage them (subscribing/unsubscribing). Futhermore, it has to be able to publish an event to all interested subscribers.

withPubSub

This behavior has been abstracted by the higher-order behavior (HOB) withPubSub, which can be used to create a behavior for an actor with publish capabilities.

The abstraction works with Behavior in particular and not machine definitions, as it is the smallest denominator to define a template for an actor. Not all actors in an actor system have to originate from state machine definitions.

When defining a behavior with withPubSub, the HOB provides a publish function to its received callback, which can be fully typed to prevent publishing events that are not part of the pub/sub contract defined by SubEvent<PublishEvent>.

import { Behavior } from "xstate";
import { spawnBehavior } from "xstate/lib/behaviors";
import { withPubSub, WithPubSub, is } from "xsystem";

type PublishEvent = { type: "hello" } | { type: "world"; payload: number };
type TriggerEvent = { type: "trigger" };

function createPublisher(): WithPubSub<
  PublishEvent,
  Behavior<TriggerEvent, null>
> {
  return withPubSub((publish) => ({
    initialState: null,
    transition: (state, event) => {
      if (is<TriggerEvent>("trigger", event)) {
        // `publish` is fully typed and publishable events are inferred.
        publish({ type: "hello" });
      } else {
        publish({ type: "world", payload: 42 });
      }

      return state;
    },
  }));
}

// Correctly typed as: ActorRef<TriggerEvent | SubEvent<PublishEvent>, null>.
// Will provide full type inference for subscribers.
const publisher = spawnBehavior(createPublisher());

Usage in Machine Definitions

To use the publish function in an machine definition, a publish action can be created from a provided publish function. It is suggested to use a factory function to pass a publish function to the machine.

import { createMachine } from "xstate";
import { createPublishAction, Publish } from "xsystem";

type PongEvent = { type: "pong" };
type PingEvent = { type: "ping" };

function createPingMachine(publish: Publish<PongEvent>) {
  const publishAction = createPublishAction(publish);

  return createMachine<{}, PingEvent>({
    id: "ping",
    initial: "waiting",
    states: {
      waiting: {
        on: {
          ping: {
            actions: [publishAction({ type: "pong" })],
          },
        },
      },
    },
  });
}

Machine Helpers

This package provides a few helpers that specifically target machine definitions to improve the ergonomics when working with machines and XSystem.

fromMachine

While state machines theoretically define behavior for actors, a state machine definition is currently not compatible with the Behavior interface in XState. Therefore, a machine can not simply be wrapped by withPubSub or other HOBs.

To solve this, the wrapper fromMachine is provided to convert a machine definition to a fully working behavior without compromises. Let us look at an example that uses the machine defined above:

import { spawnBehavior } from "xstate/lib/behaviors";
import { Publish, fromMachine, withPubSub } from "xsystem";

// The second, optional argument to `fromMachine` is the options object from `interpret`.

// Correctly typed as:
// ActorRef<PingEvent | SubEvent<PongEvent>, State<{}, PingEvent, any, { value: any; context: {}; }>>
const publisher = spawnBehavior(
  withPubSub((p: Publish<PongEvent>) =>
    fromMachine(createPingMachine(p), { devTools: true })
  )
);

fromActor

Subscribing a machine to a publisher would require sending subscribe/unsubscribe events with a send action. fromActor provides an alternative mechanism that subscribes a machine to a publisher through an invoked callback. The machine is automatically unsubscribed when the invoked callback is stopped. Let us look at an example that subscribes a machine to an event-bus actor:

import { createMachine, send } from "xstate";
import { EventBus, fromActor } from "xsystem";

type BusEvent = { type: "hello" } | { type: "world"; payload: number };

function createExampleMachine(bus: EventBus<BusEvent>) {
  return createMachine<{}, { type: "world"; payload: number }>({
    id: "signup",
    // Subscribe the machine to the "word" event.
    invoke: { id: "bus", src: fromActor(bus, ["world"]) },
    states: {
      SomeState: {
        on: {
          world: {
            target: "SomeState",
            // Events can be send to the subscribed actor through the invoked callback
            // or by using the actor ref.
            actions: [send({ type: "hello" }, { to: "bus" })],
          },
        },
      },
    },
  });
}
⚠️ **GitHub.com Fallback** ⚠️