ts-bus
A lightweight TypeScript event bus to help manage your application architecture
Example
import { EventBus, createEventDefinition } from "ts-bus";
export const someEvent = createEventDefinition<{ url: string }>()("SOME_EVENT");
const bus = new EventBus();
bus.subscribe(someEvent, event => {
alert(event.payload.url);
});
bus.publish(someEvent({ url: "https://github.com" }));
Rationale
We want to write loosely coupled highly cohesive applications and one of the best and easiest ways to do that is to use an event bus as a management layer for our applications.
This is the kind of thing that you could use effectively in most applications.
For my purposes I wanted a system that:
- Is framework agnostic can support Vue, React or Angular.
- Could enable micro-frontends / microlithic architecture.
- Can easily use React hooks to reduce state in the case of React.
- Does not conflate eventing with state management.
- Has really good TypeScript support.
Alternatives
- Redux - conflates state management with eventing and causes complexity around async as a result. React comes with state management out of the box these days anyway.
- RxJS - could make a great event bus but feels too heavy handed for use with many projects.
- Node
events
- is a little too much API for what I need here. This lib actually decorates the EventEmitter2
package. In the future I may remove it to become dependency free.
Installation
Use your favourite npm client to install ts-bus. Types are included automatically.
Npm:
npm install ts-bus
Yarn:
yarn add ts-bus
Example applications
To explore an example of ts-bs used in context pease see the KanBan example.
Usage
Create a bus
Create your EventBus globally somewhere:
import { EventBus } from "ts-bus";
export const bus = new EventBus();
Declare events
Next create some Events:
import { createEventDefinition } from "ts-bus";
export const taskCreated = createEventDefinition<{
id: string;
listId: string;
value: string;
}>()("task.created");
export const taskLabelUpdated = createEventDefinition<{
id: string;
label: string;
}>()("task.label.updated");
Notice createEventDefinition()
will often be called with out a runtime check argument and it returns a function that accepts the event type as an argument. Whilst possibly a tiny bit awkward, this is done because it is the only way we can allow effective discriminated unions. See switching on events.
Runtime payload checking
You can also provide a function to do runtime payload type checking. This might be useful if you are working in JavaScript:
import p from "pdsl";
export const taskLabelUpdated = createEventDefinition(p`{
id: String,
label: String,
}`)("task.label.updated");
Subscribing
import { taskLabelUpdated, taskCreated } from "./event";
import { bus } from "./bus";
bus.subscribe(taskLabelUpdated, event => {
const { id, label } = event.payload;
doSomethingWithLabelAndId({ id, label });
});
Unsubscribing
To unsubscribe from an event use the returned unsubscribe function.
const unsubscribe = bus.subscribe(taskLabelUpdated, event => {
});
unsubscribe();
Subscribing with a type string
You can use the event type to subscribe.
bus.subscribe("task.created", event => {
});
Or you can use wildcards:
bus.subscribe("task.**", event => {
});
Subscribing with a predicate function
You can also subscribe using a predicate function to filter events.
function isSpecialEvent(event) {
return event.payload && event.payload.special;
}
bus.subscribe(isSpecialEvent, event => {
});
You may find pdsl a good fit for creating predicates.
Subscription syntax
As you can see above you can subscribe to events by using the subscribe
method of the bus.
const unsubscriber = bus.subscribe(<string|eventCreator|predicate>, handler);
This subscription function can accept a few different options for the first argument:
- A
string
that is the specific event type or a wildcard selector eg. mything.**
. - An
eventCreator
function returned from createEventDefinition<PayloadType>()("myEvent")
- A
predicate
function that will only subscribe to events that match the predicate. Note the predicate function matches the entire event
object not just the payload. Eg. {type:'foo', payload:'foo'}
The returned unsubscribe()
method will unsubscribe the specific event from the bus.
Publishing events
Now let's publish our events somewhere
import { taskLabelUpdated, taskCreated } from "./events";
import { bus } from "./bus";
function handleUpdateButtonClicked() {
bus.publish(taskLabelUpdated({ id: "638", label: "This is an event" }));
}
function handleDishesButtonClicked() {
bus.publish(
taskCreated({ id: "123", listId: "345", value: "Do the dishes" })
);
}
Using a plain event object
If you want to avoid the direct dependency with your event creator you can use the plain event object:
bus.publish({
type: "kickoff.some.process",
payload: props.data
});
Republishing events
Lets say you have received a remote event from a websocket and you need to prevent it from being automatically redispatched you can provide custom metadata with each publication of an event to prevent re-emmission of events over the socket.
import p from "pdsl";
socket.on("event-sync", (event: BusEvent<any>) => {
bus.publish(event, { remote: true });
});
const isSharedAndNotRemoteFn = p`{
type: ${/^shared\./},
meta: {
remote: !true
}
}`;
bus.subscribe(isSharedAndNotRemoteFn, event => {
socket.emit("event-sync", event);
});
Switching on Events and Discriminated Unions
const fooCreator = createEventDefinition<{
foo:string
}>()("shared.foo");
const barCreator = createEventDefinition<{
bar:string
}>()("shared.bar");
type AppEvent = ReturnType<typeof fooCreator> | ReturnType<typeof barCreator>;
bus.subscribe("shared.**", (event:AppEvent) => {
switch(event.type){
case String(fooCreator):
alert(event.payload.foo.toLowerCase());
break;
case String(barCreator):
alert(event.payload.bar.toLowerCase());
break;
default:
}
});
Wildcard syntax
You can namespace your events using period delimeters. For example:
"foo.*" matches "foo.bar"
"foo.*.thing" matches "foo.fing.thing"
"**" matches everything eg "foo" or "foo.bar.baz"
"*" matches everything within a single namespace eg. "foo" but not "foo.bar"
This is inherited directly from EventEmitter2 which ts-bus currently uses under the hood.
React extensions
Included with ts-bus
are some React hooks and helpers that provide a bus context as well as facilitate state management within React.
BusProvider
Wrap your app using the BusProvider
import React from "react";
import App from "./App";
import { EventBus } from "ts-bus";
import { BusProvider } from "ts-bus/react";
const bus = new EventBus();
export default () => (
<BusProvider value={bus}>
<App />
</BusProvider>
);
useBus
Access the bus instance with useBus
import { useBus } from "ts-bus/react";
import { kickoffSomeProcess } from "./my-events";
function ProcessButton(props) {
const bus = useBus();
const handleClick = React.useCallback(() => {
bus.publish(kickoffSomeProcess(props.data));
}, [bus]);
return <Button onClick={handleClick}>Go</Button>;
}
useBusReducer
This connects state changes to bus events via a state reducer function.
import { useBus, useBusReducer } from "ts-bus/react";
const initialState = { count: 0 };
function reducer(state, event) {
switch (event.type) {
case "counter.increment":
return { count: state.count + 1 };
case "counter.decrement":
return { count: state.count - 1 };
default:
throw new Error();
}
}
function Counter() {
const bus = useBus();
const state = useBusReducer(initialState, reducer);
return (
<>
Count: {state.count}
<button onClick={() => bus.publish({ type: "counter.increment" })}>
+
</button>
<button onClick={() => bus.publish({ type: "counter.decrement" })}>
-
</button>
</>
);
}