The observer pattern belongs to the category of those design patterns called behavioral. This pattern allows you to manage a collection of callbacks called listeners, so they can be triggered by another object, called the subject or the observable. According to this pattern an observable should emit various events during an operation and call every registered listener for each event, in order other parts of the code to get notified.
In every emission an observable could pass data to the listeners according to the operation is running at the given time. In case an error is thrown the observable should emit a special error event along with the thrown error object. By convention an observable must expose an api in order to allow registering and removing listeners at any time. One important thing is that a registered listener must be triggered in the same order it has been registered in.
In the observer pattern an observable is an object also known as the focal point, so we can define an observable as a class having initially an empty collection of listeners. This collection is expected to be a map, where the keys are the event types and for each event type we are about to keep an ordered list of listeners meant to be triggered each time a specific event is emitted.
The way to attach a listener to an observable is straightforward, we should provide the event
type along with the listener
and append it to the list of listeners
for that specific event type. The order the listeners are attached must be attained in the whole life-cycle of the observable so they can be called in order. To be more precise the observable should define what event types emits, so at creation we initiate each event's listeners list to an empty array. Any attempt to register a listener for a not supported event type should be ignored by convention.
class Observable {
constructor () {
// A map of listeners per event
this.listeners = {
success: [],
...,
error: []
};
}
on (event, listener) {
const eventListeners = this.listeners[event];
if (eventListeners) {
// Register the listener for the given event
eventListeners.push(listener);
}
return this;
}
}
The
error
event type will be used for error handling as we'll see later on.
By this time we need a mechanism in order to broadcast events, so every listener is triggered for its corresponding event being emitted. Having the map of listeners per event we only need to iterate through and call them in the specified order along with any data arguments. The data arguments could be any valid value given as separated arguments.
class Observable {
...
emit (event, ...args) {
const eventListeners = this.listeners[event];
if (eventListeners) {
// Trigger the listeners for the given event
eventListeners.forEach((listener) => {
listener.call(null, ...args);
});
}
}
}
We could call the listener with
this
instead ofnull
in case we need to have access the observable within the listener's code via thethis
operator.
Bear in mind that this method should always be called from an asynchronous context, for instance an operation of the observable running asynchronously.
An observable must have a purpose, it must provide at least one asynchronous operation
. It doesn't have to be an asynchronous operation, even though it only makes sense to have an asynchronous observable. Within each operation the observable must use the emit
method in order to call every listener given this event type along with any data arguments.
class Observable {
...
async operation () {
// Execute any business logic
const valueA = await ...
const valueB = await ...
...
// Emit the success of the operation
this.emit("success", valueA, valueB, ...);
}
}
Operation is set to be an async function in order to call operations asynchronously.
Having the observable class we can now create an instance of it and register event listeners by using the on
method given the event type and the listener.
const observable = new Observable();
observable.on("success", (value) => {...});
observable.on("error", (error) => {...});
Because the observable's
on
method returns the observable object itself, we can use chaining as well.
A special care must be taken regarding the error handling in the observer pattern. Every time an error
is caught in an operation
of an observable we must emit with the special event type of error
. The emission by convention must be triggered along with a given valid Error
object.
class Observable {
...
async operation () {
try {
// Execute any business logic
const valueA = await ...
const valueB = await ...
...
// Emit the success of the operation
this.emit("success", valueA, valueB, ...);
} catch (error) {
this.emit("error", error); // Emit the thrown error
}
}
}
To sum up, an observable must encapsulate a map of listeners (observers) per event type according to our requirements and broadcast events so the listeners mapped by the emitted event are executed. A special event is the error which must be triggered any time an exception is thrown. All those broadcasts and emissions must happen within an asynchronous operation so no listeners are swallowed by operations executed before the listeners being registered. Now let's put all this together.
class Observable {
constructor () {
// A map of listeners per event
this.listeners = {
success: [],
...,
error: []
};
}
on (event, listener) {
const eventListeners = this.listeners[event];
if (eventListeners) {
// Register the listener for the given event
eventListeners.push(listener);
}
return this;
}
emit (event, ...args) {
const eventListeners = this.listeners[event];
if (eventListeners) {
// Trigger the listeners for the given event
eventListeners.forEach((listener) => {
listener.call(null, ...args);
});
}
}
async operation () {
try {
// Execute any business logic
const valueA = await ...
const valueB = await ...
...
// Emit the success of the operation
this.emit("success", valueA, valueB, ...);
} catch (error) {
this.emit("error", error); // Emit the thrown error
}
}
}
const observable = new Observable();
observable.on("success", (value) => {...});
observable.on("error", (error) => {...});
observable.operation();
The observer pattern is a very powerful mechanism which if not taken seriously it might introduce some kind of degradation in the performance of your application. Each time a new listener is attached to an observable occupies memory because of the surrounding closure, that portion of allocation must be released when the listener not needed anymore. So we always must provide a method to remove registered listeners from an observable object. In order to remove a listener we must provide both the event type and the listener.
class Observable {
...
removeListener (event, listener) {
const eventListeners = this.listeners[event];
if (eventListeners) {
// Find the listener and remove it
...
}
}
}
What do we mean by swallowing listeners is that if we try to emit an event synchronously there is possibility to miss listeners registered after the emission of the event. Let's say we need to emit an event each time we construct an observable, anyone could think that the following code should do the job.
class Observable {
constructor () {
this.emit("create");
}
...
}
But what is happening when we call the following code is that we will miss the create
event so the listener is never getting called.
const observable = new Observable(); // Synchronously emits the `create` event
observable.on("create", () => {
console.log("A new observable is created"); // This is never called
});
What we need to do is to always emit events asynchronously in the next event loop cycle in order to let the listeners to be registered within the same event loop cycle and be available for invocation.
class Observable {
constructor () {
// Emit the event in the next event loop cycle
setTimeout(() => this.emit("create"));
}
...
}
A really reasonable request could be to give access to the observable's state within the code of each registered listener. We can do this by just calling each listener with the this
, like so:
class Observable {
...
emit (event, ...args) {
const eventListeners = this.listeners[event];
if (eventListeners) {
eventListeners.forEach((listener) => {
// Set the observable as the `this` in listener's code
listener.call(this, ...args);
});
}
}
}
Now there is a caveat here, when you're registering a listener to the observable you have to use a function expression instead of an arrow function, otherwise the this
within the listener's code will point to where the this
is referring in the outer lexical scope and not the observable.
const observable = new Observable();
observable.on("success", function (data) {
const value = this.value; // Get access to the observable
...
});
Below you can find various trivial or real-world implementations of this pattern:
- Thermometer: A trivial implementation of an observable thermometer sensor