Skip to main content
Version: 0.1.x

Events

SALVO-TS components can define custom events which can be listened on by other components, allowing cross-component communication.

Don't invent an event bus

Avoid inventing an 'event bus' - a global or generic object through which many events are routed and fired. Event buses make it unclear where components depend on eachother and make failures harder to troubleshoot. Events should belong to a clear owner, and subscribing to them should require explicitly getting a reference to that owner.

The cases where event buses are typically used, i.e. for subscribing to cart chagnes, can be achieved just as easily by using global events on the appropriate objects & services within the theme. For example, the Cart object implements an onChange event - any component in the theme that wants to manipulate th cart can use the Cart object, and any component which wants to response to changes can subscribe to onChange. However, it is always the Cart object which is responsible for firing the event. This is very similar to an event bus, but the main difference is that the 'owner' is more explicit, and is the only entity responsible for triggering the event. The onChange event is part of the Cart object, and only the Cart object can fire it. This keeps the separation of responsibilities clear and keeps the logic flowing in a single direction, helping to avoid issues such as infinite loops, and making the logic flow easier ho debug when such issues do occur.

The exception to this is global, session-level events, like "cart updated" - these are already implemented at the framework level (i.e. this.cart.onChange) and should not be replicated in your theme. A need for similar events may exist if your project makes use of other 'global services', like a "garage service" on a car website - in these cases, events should be implemented directly on those service interfaces, which should exist separately to components.

Listening to events in your components

If you add event listeners in your components, it's cruicial that you remove them again when your component is destroyed. This is because components can be destroyed and re-init multiple times on a single page. If you don't clean up your listeners in between, you'll end up with duplicate listeners which cause issues that are a nightmare to troubleshoot.

At it's most basic, this means removing event listeners in destroy():

ts
@Component
export class ResizeAlerter extends ThemeComponent {
resizeListener: (() => void)|null = null;
init() {
if (!this.resizeListener) {
this.resizeListener = () => this.resizeOccured();
}
window.addEventListener('resize', this.resizeListener);
}
destroy() {
if (this.resizeListener) {
window.removeEventListener('resize', this.resizeListener);
}
}
private resizeOccurred() {
this.$log.info('The window was resized!');
}
}
ts
@Component
export class ResizeAlerter extends ThemeComponent {
resizeListener: (() => void)|null = null;
init() {
if (!this.resizeListener) {
this.resizeListener = () => this.resizeOccured();
}
window.addEventListener('resize', this.resizeListener);
}
destroy() {
if (this.resizeListener) {
window.removeEventListener('resize', this.resizeListener);
}
}
private resizeOccurred() {
this.$log.info('The window was resized!');
}
}
Details: Why is this so cumbersome? Why not window.addEventListener('resize', this.resizeOccured)?

In order to maintain the this for this.$log, we'd need to use window.addEventListener('resize', this.resizeOccurred.bind(this)).

However, this would prevent us from removing the listener again - window.removeEventListener('resize', this.resizeOcurred) won't work since this.resizeOccured is different to the instance of the function that's returned by this.resizeOccurred.bind(this).

Yes, this is very cumbersome. Use the $listen helper that's described below in real code.

This is very common, so you should use the $listen helper method to achieve the same thing:

ts
@Component
export class ResizeAlerter extends ThemeComponent {
resizeListener: (() => void)|null = null;
init() {
this.$listen(window, 'resize', () => this.resizeOccurred());
}
destroy() {
// No need for anything here, $listen will clean up automatically
}
private resizeOccurred() {
this.$log.info('The window was resized!');
}
}
ts
@Component
export class ResizeAlerter extends ThemeComponent {
resizeListener: (() => void)|null = null;
init() {
this.$listen(window, 'resize', () => this.resizeOccurred());
}
destroy() {
// No need for anything here, $listen will clean up automatically
}
private resizeOccurred() {
this.$log.info('The window was resized!');
}
}

Event listeners added with $listen are automatically removed when the component is destroyed.

Custom events

Components and objects like the Cart can use custom events to notify eachother of changes, just like native elements do. In order to achieve this in a convenient and type-safe way, an interface named SimpleEvent is provided.

Details: Why not the native CustomEvent?

The browser's native CustomEvent is good, but it's got a few issues that make it difficult in real projects:

  • Passing payloads is cumbersome. You have to cont evt = new CutomEvent and then evt.detail = payload, and you have to call evt.detail to get te payload back at the other end.
  • The detail on CustomEvent isn't typed, so you have to assert the type at each end.

The SimpleEvent class has a much more convenient interface and leads to clearer, safer code.

The SimpleEvent class provides subscribe and dispatch functions for listening and firing event events respectively. These are roughly equiviliant to addEventListener and dispatchEvent:

ts
import { SimpleEvent } from '@eastsideco/salvo-ts';
const onNameChange = new SimpleEvent<string>();
onNameChange.subscribe((name) => {
this.$log.info(`Hello ${name}!`);
});
onNameChange.dispatch('there');
onNameChange.dispatch('World');
onNameChange.dispatch('John Doe');
// Outputs:
// Hello there
// Hello World
// Hello John Doe
ts
import { SimpleEvent } from '@eastsideco/salvo-ts';
const onNameChange = new SimpleEvent<string>();
onNameChange.subscribe((name) => {
this.$log.info(`Hello ${name}!`);
});
onNameChange.dispatch('there');
onNameChange.dispatch('World');
onNameChange.dispatch('John Doe');
// Outputs:
// Hello there
// Hello World
// Hello John Doe

Note that a SimpleEvent can have multiple subscriptions. When it is dispatched, all of the subscribers will be called.

Event payload type

You can pass any type as the event payload. If you've got a particular complex, consider creating a dedicated payload type for it. This makes writing listeners a little easier as you can use the type for the parameters.

ts
interface ComplexEventPayload {
foobar: {
name: string;
enabled: boolean;
};
option: 'a'|'b';
}
// Creating event
const complexEvent = new SimpleEvent<ComplexEventPayload>();
// Listening
function listener(payload: ComplexEventPayload) {
// ...
}
complexEvent.subscribe(listener);
// Firing
complexEvent.dispatch({
foobar: {
name: 'guest',
enabled: true,
},
option: 'b',
});
ts
interface ComplexEventPayload {
foobar: {
name: string;
enabled: boolean;
};
option: 'a'|'b';
}
// Creating event
const complexEvent = new SimpleEvent<ComplexEventPayload>();
// Listening
function listener(payload: ComplexEventPayload) {
// ...
}
complexEvent.subscribe(listener);
// Firing
complexEvent.dispatch({
foobar: {
name: 'guest',
enabled: true,
},
option: 'b',
});

:::

Component events

Events are most often defined as properties on components. You should use a backing field and expose the value of .asEvent() publically - this exposes a version of the event without the dispatch methods, and ensures only the owner class can dispatch the event (this makes debugging simplier.)

ts
import { SimpleEvent } from '@eastsideco/salvo-ts';
@Component
export class MyComponent extends ThemeComponent {
private name: string = 'Guest';
// Private backing field and public event property
private _onNameChange = new SimpleEvent<string>();
public get onNameChange() {
return this._onNameChange.asEvent();
}
public changeName(name: string) {
this.name = name;
// Use the private backing field to dispatch
this._onNameChange.dispatch(name);
}
}
// Use the public property for subscribing
myComponent.onNameChange.subscribe((name) => {
this.$log.info(`Hello ${name}`);
});
// Prevented from dispatching the event from outside the class
myComponent.onNameChange.dispatch('foo'); // ERROR: dispatch doesn't exist on onNameChange
ts
import { SimpleEvent } from '@eastsideco/salvo-ts';
@Component
export class MyComponent extends ThemeComponent {
private name: string = 'Guest';
// Private backing field and public event property
private _onNameChange = new SimpleEvent<string>();
public get onNameChange() {
return this._onNameChange.asEvent();
}
public changeName(name: string) {
this.name = name;
// Use the private backing field to dispatch
this._onNameChange.dispatch(name);
}
}
// Use the public property for subscribing
myComponent.onNameChange.subscribe((name) => {
this.$log.info(`Hello ${name}`);
});
// Prevented from dispatching the event from outside the class
myComponent.onNameChange.dispatch('foo'); // ERROR: dispatch doesn't exist on onNameChange

Just as with normal events, it's important to clean up any component events that you subscribe to. You can use the $subscribe helper for this, which works just like the $listen helper but for SimpleEvent events:

ts
@Component
export class OtherComponent extends ThemeComponent {
@Ref myComponent!: MyComponent;
init() {
this.$subscribe(this.myComponent.onNameChange, this.nameChanged.bind(this));
}
destroy() {
// No need to do anything here, $subcribe will clean up automatically
}
private nameChanged(name: string) {
this.$log.info(`Hello ${name}`);
}
}
ts
@Component
export class OtherComponent extends ThemeComponent {
@Ref myComponent!: MyComponent;
init() {
this.$subscribe(this.myComponent.onNameChange, this.nameChanged.bind(this));
}
destroy() {
// No need to do anything here, $subcribe will clean up automatically
}
private nameChanged(name: string) {
this.$log.info(`Hello ${name}`);
}
}