-
Notifications
You must be signed in to change notification settings - Fork 5
01 Event Subscriptions
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.
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.
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.
-
commit
is matched by*
andcommit
-
user.commit
is matched by*
,user.*
, anduser.commit
-
user.profile.commit
is matched by*
,user.*
,user.profile.*
, anduser.profile.commit
- ...
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.
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());
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" })],
},
},
},
},
});
}
This package provides a few helpers that specifically target machine definitions to improve the ergonomics when working with machines and XSystem.
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 })
)
);
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" })],
},
},
},
},
});
}