Event Dispatcher

The EventDispatcher component offers you an easy way for your application's components to communicate witch each other.

You can use it as a standalone class (by instantiating it yourself) or use the EventDispatcherService available through the Injector as a singleton.

It is located in the @banquette/event package.

Use the global service

To import the shared instance of EventDispatcher, simply do the following:

import { Injector } from "@banquette/core";
import { EventDispatcherService } from "@banquette/event";

const eventDispatcher = Injector.Get(EventDispatcherService);

The instance being shared, any part of the app that will subscribe to an event on this instance will be in reach of any other part triggering the event.

Subscribe to an event

Subscribing to an event is simply the act of registering a callback that will be called when the event is triggered. You can do it like this:

eventDispatcher.subscribe(MyEvent, (event: EventArg) => {
    // Logic the execute when the event is fired.
});

MyEvent is a symbolopen in new window and not a string like you would probably expect. The goal is to eliminate the risk of typo or duplicate. Because a Symbol is always unique, it cannot collide with an existing one even if you set the same description:

Symbol('test') === Symbol('test'); // false

It also enforces best practices because you have to assign the Symbol to a variable in order to subscribe to the event associated with it:

// events.ts
export const MyEvent = Symbol('MyEvent');

// app.ts
import { MyEvent } from './events';

eventDispatcher.subscribe(MyEvent, () => {
    // ...
});

Dispatch an event

To dispatch an event, simply call the dispatch method. Using it can be as simple as:

eventDispatcher.dispatch(MyEvent);

You can optionally give an object extending the EventArg class as a second argument if you need to send data to the subscribers:

import { EventArg } from '@banquette/event';

class UserLoginEvent extends EventArg {
    public constructor(public userProfile: any) { }
}

eventDispatcher.dispatch(MyEvent, new UserLoginEvent(userProfile));

The DispatchResult object

dispatch() always returns a DispatchResult object synchronously, which contains the following data:

Option Description
status The status of the result, can be one of:
- DispatchResultStatus.Waiting
- DispatchResultStatus.Ready
- DispatchResultStatus.Error
A flag is also available for each status so it's easier to check for a specific status: waiting, ready, error.
results An array containing the return value of each subscriber's callback.
Warning: when dealing with asynchronous events, the order is only guaranteed if the sequential option is true.
promise A promise that will resolve when all events have been dispatched.
Will only be set if at least one of the subscribers return a promise, otherwise it will be null.

Warning: this promise will always resolve, even if a subscriber throw an exception. In such a case, the result will have the status DispatchResultStatus.Error and the exception will be available through the errorDetail property (see below).
errorDetail Holds the exception thrown by a subscriber. It is not an array because the dispatch will stop on the first error.

Sync vs Async

As we've seen above, dispatch() always respond synchronously with a DispatchResult object.

If you're dealing with asynchronous subscribers, the promise property will be set to a promise that will only resolve when all subscribers have been called and have returned a non promise result or their promise have resolved too.

For example:

eventDispatcher.subscribe(MyEvent, async () => {
    await someLongProcessing();
    return 'result!';
});

const result = eventDispatcher.dispatch(MyEvent);
// result.ready: false
// result.waiting: true

result.promise.then(() => {
    // result.ready: true    
    // result.results: ['result!']
});

Order of execution

If you have multiple asynchronous events, maybe you will need them to execute in order. You can do this by setting the sequential option to true when calling dispatch():

eventDispatcher.subscribe(MyEvent, async () => {
    await waitForDelay(200);
    return 'a';
});

eventDispatcher.subscribe(MyEvent, async () => {
    await waitForDelay(100);
    return 'b';
});

const result = eventDispatcher.dispatch(
    MyEvent, // The event type
    null,    // The EventArg object (here we have none)
    true     // The "sequential" option
);

result.promise.then(() => {
    // result.results: ['a', 'b']
});

Here b have been executed after the promise return by a have resolved.

With sequential to false (the default value), a and b would have executed at the same time, and because b takes significantly less time to execute, the result would have been: ['b', 'a'].


Get a response

As we've seen above, an array containing all the values returned by subscribers is stored in the response.

You should not try to access it until the status of the result has changed to DispatchResultStatus.Ready.

You can use the onReady() method to be notified when the result is ready, no matter if synchronous subscribers are part of the dispatch or not:

eventDispatcher.dispatch(MyEvent).onReady().then((result: DispatchResult) => {
    // Here you are guaranteed the status is either "ready" or "error".
});

TIP

onReady always return a Promise, even if all subscribers are synchronous. This also means you can use it with async/await like this:

const result = await eventDispatcher.dispatch(MyEvent).onReady();
// result guaranteed to be ready.

Unsubscribe

To unsubscribe from an event, you just have to call the function returned by subscribe:

const unsubscribeFn = eventDispatcher.subscribe(MyEvent, () => { });

// Call the function to unsubsribe
unsubscribeFn();

You can also call clear to remove all subscribers to an event:

eventDispatcher.clear(MyEvent);

Or clear all subscribers of every event:

eventDispatcher.clear();

Priority

If you need to control the order in which the subscribers are called, you can set a priority as third argument of the subscribe method:

eventDispatcher.subscribe(MyEvent, () => {
    // Your logic
}, 10 /* The actual priority */ );

The events are simply ordered by descending priority, so the higher the number the sooner a subscriber will be called.

TIP

The default priority is 0.

Propagation

When you emit an event, the registered callbacks are called one by one, by priority order.

If you want to stop the chain of calls, you can call stopPropagation() on the EventArg passed as first argument of your callback:

import { EventArg } from "@banquette/event";

eventDispatcher.subscribe(MyEvent, (event: EventArg) => {
    event.stopPropagation();
});

Prevent default

Just like you would do with a DOM event, you can tell the emitter of the event that you don't want the default behavior to execute by calling preventDefault() on the EventArg:

import { EventArg } from "@banquette/event";

eventDispatcher.subscribe(MyEvent, (event: EventArg) => {
    event.preventDefault();
});

That's then the responsibility of the emitter to check the value of the flag if there is a preventable action to perform next:

import { EventArg } from "@banquette/event";

const event = new EventArg();
eventDispatcher.dispatch(MyEvent, event);
if (!event.defaultPrevented) {
    // Do the action.
}

Tags

Tags are a way to filter dispatched events. There are two types of tags:

  1. Filtering tags: tags used to filter the callbacks that will be called when an event is dispatched
  2. Propagation tags: tags used to control the callbacks affected when stopping the propagation

Filtering tags

Filtering tags allow you to limit the dispatch of an event to certain callbacks.

For example, here only the subscribers with the tag MyTag will be called by this dispatch:

eventDispatcher.dispatch(MyEvent, new EventArg(), false, [MyTag] /* tags */);
eventDispatcher.subscribe(MyEvent, () => {
    // Will NOT be called
});

eventDispatcher.subscribe(MyEvent, () => {
    // Will be called
}, 0, [MyTag, OtherTag]);

But why don't just create another event?

A real world usage is the HttpService. It emits several events when performing an ajax request: BeforeRequest, BeforeResponse, etc.

But many unrelated components use the service. Without the filtering tags, if one of them wants to listen for the BeforeRequest event, it would be called for any request, no matter the component they originate from:

eventDispatcher.subscribe(HttpEvents.BeforeRequest, (event) => {
    // Without filtering tags you would have to ensure the request concerns you. 
    // And it could be hard, even impossible in some scenarios.
});

But because the HttpService uses filtering tags when dispatching its events, you can do this:

eventDispatcher.subscribe(HttpEvents.BeforeRequest, (event) => {
    // Here the callback will only be called for requests tagged as api requests.
}, 0, [ApiRequest]);

TIP

In case you wonder how the HttpService knows what tag to dispatch the event with, it is part of the configuration of the request:

httpService
    .build()
    .get()
    .url('/example')
    .tag(ApiTag)
    [...]

If you're not familiar with the HttpService, you can learn more in the dedicated section.


Propagation tags

The other type of tags is related to the propagation. It offers a way to isolate certain events when stopping the propagation.

The HttpService is again a very good example of a use case because of the BeforeRequest event it emits. It is meant to let the end-user do some processing before the request goes (like encoding the payload):

eventDispatcher.subscribe(HttpEvents.BeforeRequest, (event: RequestEvent) => {
    // We encode the payload as a JSON string.
    event.request.payload = JSON.stringify(event.request.payload);
});

But if you have multiple subscribers doing different type of encoding (like json and xml), you may want to stop after the first one that modified the payload. And to do this, you will have stop the propagation on the callback:

eventDispatcher.subscribe(HttpEvents.BeforeRequest, (event: RequestEvent) => {
    // Do the processing...
    
    // Then prevent other subscribers to be called.
    event.stopPropagation();
});

But by doing this, you prevent all other subscribers from being called. What if you have another subscriber that logs the request for example?

Adding a propagation tag helps to solve this problem. In our example you could create a EncoderTag tag that will be set on all encoders subscribers:

eventDispatcher.subscribe(
    BeforeRequest,                    // Event type
    (event: RequestEvent) => {...},   // Callback 
    0,                                // Priority
    [],                               // Filtering tags
    [EncoderTag]                      // Propagation tags
);

Now the call to stopPropagation will only affect subscribers that have at least 1 tag in common with the subsrciber that called stopPropagation.

TIP

Tags are symbols, for the same reasons as previously discussed for the events types.

Use your own instance

In some cases you may want to create a separate dispatcher for a limited part of your app. That's not a problem as the dispatcher is a simply class you can use as a standalone:

import { EventDispatcher } from "@banquette/event";

const myOwnDispatcher = new EventDispatcher();

It's up to you to then make it accessible to the components that will communicate through it.

If you want to register your own dispatcher into the Injector, you will have to create a class that inherit from EventDispatcher and add a @Service decorator to it:

import { EventDispatcher } from "@banquette/event";

@Service()
class MyCustomDispatcherService extends EventDispatcher {
    
}

You can then use it just like any other service:

Injector.Get(MyCustomDispatcherService).dispatch(...);