Decoupling components

Deciding how components of a program depend on each other and communicate with one another is what design is all about. Designing an Aurelia application is no different. However, in order to make enlightened design choices, you need to know what techniques the framework offers.

There are typically four ways to make components communicate in an Aurelia application: using data binding, using remote services, using shared services, and using events.

Up to now, our application has mostly relied on data binding and on a remote service, our backend. The route components don't directly communicate with each other, but do so via the backend. Each route component retrieves the data it needs from the backend each time it is activated, then delegates any action performed by the user back to the backend. Additionally, route components are composed of other reusable components, and communicate with them using data binding.

In the following sections, we will start by quickly summarizing the techniques we have already used, then we will discuss the other techniques: events and shared services. In doing so, we will also heavily refactor our contact management application, so we can try a whole different architecture based on those techniques.

As an experiment, we will first refactor our application so we can listen for and locally dispatch events sent by the backend when things happen. This way, any component that needs to react to such events can simply subscribe to the local event.

Once this is done, we will use those local events to refactor our application further, this time toward real-time, multi-user synchronization. We will create a service that will load the list of contacts and then listen for change events to keep its contacts synchronized. We will refactor all route components so they retrieve their data from this local list of contacts instead of fetching it from the backend at each activation.

The flow will be similar to this:

Decoupling components

When a user performs an action, such as creating a new contact or updating an existing one, a command will be sent to the backend. This doesn't change. However, instead of reloading the whole dataset from the backend each time the contact list component is displayed, the application will simply display its local copy of the data because it will keep it up-to-date by listening for change events, which are emitted by the backend every time a command is sent.

This new design borrows some concepts from the CQRS/ES patterns. One advantage of this pattern is that the application will be notified instantly each time any user makes a change to the data, so the application is constantly synchronized with the state of the server.

Note

CQRS stands for Command and Query Responsibility Segregation, and ES stands for Event Sourcing. Defining those patterns being way outside the scope of this book, you can check what Martin Fowler has to say about them if you are curious: http://martinfowler.com/bliki/CQRS.html and http://martinfowler.com/eaaDev/EventSourcing.html.

Of course, this whole synchronization mechanism would require some form of conflict management in a production-ready application. Indeed, when a user is editing a contact, if another user makes a change to the same contact, the first user will see the form being updated on the fly and the new values overwrite his own changes. That would be bad. However, we will not go this far. Let's consider this as a proof of concept and an experiment on ways to make components communicate.

Using data binding

The most common and simple way to make components communicate is through data binding. We have already seen plenty of examples of this; when we bound the edit component's contact property with the form component's contact bindable property, we made them communicate.

Data binding allows the loose coupling of components within a template. Of course, it has some intrinsic limitations: binding is declared by the parent component and communication is limited to a single layer of components in the application tree. Making the communication go more than one level requires each component in the tree to be data-bound to its children. We can see this in the photo component, whose files property is bound to the files property of file-picker, which is in turn bound to the file-drop-target attribute, enabling communication across multiple layers of components.

It is also the more flexible way to make components communicate because it is extremely easy to change and because the dependency lies within the template, where the components themselves are declared and composed.

Using remote services

Another way to make components communicate is via a remote service. We have also used this technique a lot in our application. The application stores very little state; the backend is the actual repository of state.

In order to display a contact for modification, the edition component queries the backend for the contact's data. When the user saves the contact's modifications, an update command is sent to the backend, which applies the changes to its internal state. Then, when the application brings the user back to the contact's details, the component queries a fresh copy of the contact's data. The same happens when navigating to the contacts list: the backend is queried each time and the whole list of contacts is fetched every time.

This technique is very common. In such cases, an application considers its backend to be the sole source of the truth, and relies on it for everything. Such applications can be much simpler because things like business rules and the complex side effects of commands can be completely handled by the backend. The application is simply a rich user interface sitting on top of the backend.

However, the downside of this technique is that the application is useless if the communication line goes down. In situations of network failure, or when the backend is irresponsive for some reason, the application doesn't work anymore.

Using events

One design technique widely used to reduce coupling is the publish/subscribe pattern. When applying this pattern, components can subscribe to a message bus so they are notified when specific types of messages are sent. Other components can then use this same message bus to send messages, without knowing which components will handle them.

Using this pattern, the various components don't have any dependency on each other. Instead, they all depend on the message bus, which acts like a kind of abstraction layer between them. Additionally, this pattern greatly increases the flexibility and extensibility of the design, as new components can very easily subscribe to existing message types without any need to change other components.

Aurelia offers, through its aurelia-event-aggregator library, an EventAggregator class, which can act as such a message bus. We will see how we can benefit from this class in the following section.

The event aggregator

The aurelia-event-aggregator library is part of the default configuration, so, by default, we don't need to install or load anything to use it.

This library exports the EventAggregator class, which exposes three methods:

  • publish(name: string, payload?: any): void: Publishes a named event along with an optional payload.
  • subscribe(name: string, callback: function): Subscription: Subscribes to a named event. The callback function will be called each time an event is published with the subscribed name. The payload passed to the publish method will be passed to the callback function as its first argument.
  • subscribeOnce(name: string, callback: function): Subscription: Subscribes to a named event, but only once. The subscription will be automatically disposed the first time the event is published. The subscription is returned, so it can even be disposed manually before the event is ever published.

The Subscription object returned by the subscribe and subscribeOnce methods has a single method, named dispose. This method simply removes the callback function from the registered handlers so it won't be called anymore when the event is published.

For example, some component could publish an event named something-happened using the following code:

import {inject} from 'aurelia-framework'; 
import {EventAggregator} from 'aurelia-event-aggregator'; 
 
@inject(EventAggregator) 
export class SomeComponent { 
  constructor(eventAggregator) { 
    this.eventAggregator = eventAggregator; 
  }       
 
  doSomething(args) { 
    this.eventAggregator.publish('something-happened', { args }); 
  } 
} 

Here, the component's constructor will be injected with an EventAggregator instance, which is then stored on the component. Then, when the doSomething method is called, an event named something-happened is published on the event aggregator. The event's payload is an object with an args property, which contains the args parameter that was passed to the doSomething method.

In order to react to this event, another component could subscribe to it:

import {inject} from 'aurelia-framework'; 
import {EventAggregator} from 'aurelia-event-aggregator'; 
 
@inject(EventAggregator) 
export class AnotherComponent { 
  constructor(eventAggregator) { 
    this.eventAggregator = eventAggregator; 
  }       
 
  activate() { 
    this.subscription = this.eventAggregator.subscribe('something-happened', e => { 
      console.log('Something happened.', e.args); 
    }); 
  } 
 
  deactivate() { 
    this.subscription.dispose(); 
  } 
} 

Here, the other component's constructor is also injected with the event aggregator, which is stored on the component. When activated, the component starts listening for something-happened events, so it can write a log to the browser's console each time one is published. It also keeps a reference to the subscription, so it can dispose it and stop listening for the event when deactivated.

Such a pattern is very common when working with the event aggregator in a component. Using it makes sure that components listen for events only when they are active. It also prevents memory leaks; indeed, a component cannot be garbage-collected if the event aggregator still holds a reference to it.

Extending an object with events

In addition to the EventAggregator class, the aurelia-event-aggregator library also exports a function named includeEventsIn. It expects an object as its single argument.

This function can be used to extend an object with the event aggregator's functionality. It will create an EventAggregator instance internally and add to the object a publish, a subscribe, and a subscribeOnce method, all delegating to this new EventAggregator instance's corresponding method.

For example, by calling this function in a class constructor, you can make all instances of the class have their own local events. Let's imagine the following class:

import {includeEventsIn} from 'aurelia-event-aggregator'; 
 
export class SomeModel { 
  constructor() { 
    includeEventsIn(this); 
  }       
 
  doSomething() { 
    this.publish('something-happened'); 
  } 
} 

The something-happened event can be subscribed directly on a SomeModel instance:

const model = new SomeModel(); 
model.subscribe('something-happened', () => { 
  console.log('Something happened!'); 
}); 

Since each instance has its own private EventAggregator instance, the events won't be shared across the whole application or even across multiple instances. Instead, the events will be scoped to each instance individually.

Using event classes

The publish, subscribe, and subscribeOnce methods can be used with named events, but they also support typed events. As such, the following signatures are also valid:

  • publish(event: object): void: Publishes an event object. Uses the prototype of the object as the key to select the callback functions to call.
  • subscribe(type: function, callback: function): Subscription: Subscribes to a type of event. The callback function will be called each time an event that is an instance of the subscribed type is published. The published event object itself will be passed to the callback function as its single argument.
  • subscribeOnce(type: function, callback: function): Subscription: Subscribes to a type of event, but only once.

As an example, let's imagine the following event class:

export class ContactCreated { 
  constructor(contact) { 
    this.contact = contact; 
  } 
} 

Publishing such an event would be done this way:

eventAggregator.publish(new ContactCreated(newContact)); 

Here, we can imagine that the eventAggregator variable contains an instance of an EventAggregator class, and that the newContact variable contains some object representing a newly created contact.

Subscribing to this event would be done like this:

eventAggregator.subscribe(ContactCreated, e => { 
  console.log(e.contact.fullName); 
}); 

Here, the callback will be called each time a ContactCreated event is published, and its e argument will be the ContactCreated instance that was published.

Moreover, the EventAggregator supports inheritance when working with event classes. This means that you can subscribe to an event base class and the callback function will be called each time any event class inheriting from this base class is published.

Let's go back to our previous example and add some event classes:

export class ContactEvent { 
  constructor(contact) { 
    this.contact = contact; 
  } 
} 
 
export class ContactCreated extends ContactEvent { 
  constructor(contact) { 
    super(contact); 
  } 
} 

Here, we define a class named ContactEvent, from which the ContactCreated class inherits.

Now let's imagine the two following subscriptions:

eventAggregator.subscribe(ContactCreated, e => { 
  console.log('A contact was created'); 
}); 
eventAggregator.subscribe(ContactEvent, e => { 
  console.log('Something happened to a contact'); 
}); 

After this code is executed, if an instance of ContactEvent is published, the text Something happened to a contact will be logged to the console.

However, if an instance of ContactCreated is published, both texts A contact was created and Something happened to a contact will be logged to the console because the event aggregator will go up the prototype chain and try to find subscriptions for all ancestors. This feature can be pretty powerful when dealing with complex hierarchies of events.

Class-based events add some structure to messaging, as they force an event payload to respect a predefined contract. Depending on your style of programming, you may prefer using strongly-typed events instead of named events with untyped payloads. It fits particularly well for typed JS supersets such as TypeScript.

Creating an interactive connection

The following being some kind of experiment, or proof of concept, I suggest that you somehow backup your application at this point, either by simply copying and pasting the project directory, or by creating a branch on your source control if you cloned the code from GitHub. This way, you'll be able to start back at the current point when you go on to the next chapter.

Note

Additionally, the sample found at chapter-6/samples/app- using-server-events illustrates the application modified as depicted in the following sections. It can be used as a reference.

The backend we use accepts interactive connections in order to dispatch events to client applications. Using such an interactive connection, it can notify connected clients every time a contact is either created, updated, or deleted. To dispatch those events, the backend relies on the WebSocket protocol.

Note

The WebSocket protocol allows for long-lived, two-way connections between a client and a server. As such, it allows the server to send event-based messages to the connected clients.

In this section, we will create a service named ContactEventDispatcher. This service will create a WebSocket connection with the backend and will listen for change events from the server to locally dispatch them through the application's event aggregator.

In order to create an interactive connection to the server, we will use the socket.io library.

Note

The socket.io library offers a client implementation and a node.js server for interactive connections, both supporting WebSocket and offering fallback implementations when WebSocket is not supported. The backend already uses this library to handle interactive connections from the application. It can be found at http://socket.io/.

Let's first install the socket.io client. Open a console in the project's directory and run the following command:

> npm install socket.io-client --save

Of course, the new dependency must be added to the application's bundle. In aurelia_project/aurelia.json, under build, then bundles, in the dependencies section of the bundle named vendor-bundle.js, add the following entry:

{ 
  "name": "socket.io-client", 
  "path": "../node_modules/socket.io-client/dist", 
  "main": "socket.io.min" 
}, 

We can now create the ContactEventDispatcher class. This class being a service, we will create it in the contacts feature's services directory:

src/contacts/services/event-dispatcher.js

import {inject} from 'aurelia-framework'; 
import io from 'socket.io-client'; 
import environment from 'environment'; 
import {EventAggregator} from 'aurelia-event-aggregator'; 
import {Contact} from '../models/contact'; 
 
@inject(EventAggregator) 
export class ContactEventDispatcher { 
 
  constructor(eventAggregator) { 
    this.eventAggregator = eventAggregator; 
  } 
 
  activate() { 
    if (!this.connection) { 
      this.connection = io(environment.contactsUrl); 
 
      this.connecting = new Promise(resolve => { 
        this.connection.on('contacts.loaded', e => { 
          this.eventAggregator.publish('contacts.loaded', { 
            contacts: e.contacts.map(Contact.fromObject) 
          }); 
          resolve(); 
        }); 
      }); 
    } 
 
    return this.connecting; 
  } 
 
  deactivate() { 
    this.connection.close(); 
    this.connection = null; 
    this.connecting = null; 
  } 
} 

This class requires an EventAggregator instance to be passed to its constructor and declares an activate method, which uses the io function imported from the socket.io client library to create a connection with the server using the contactUrl of environment. It then creates a new Promise, which is assigned to the connecting property and returned by the activate method. This Promise allows the monitoring of the state of the connection process to the backend, so callers can hook into it to react when the connection is established. In addition, the method also makes sure that only one connection to the backend is opened at any given time. If activate is called multiple times, the connectingPromise is returned.

When the backend receives a new connection, it sends the current list of contacts as an event named contacts.loaded. As such, once the activate method initializes the connection, it listens for this event to republish it on the event aggregator. In doing so, it also transforms the initial list of objects received from the server in an array of Contact objects. It finally resolves the connectingPromise to notify the caller that the activate operation is completed.

The class also exposes a deactivate method, which closes and clears the connection.

At this point, the dispatcher publishes a contacts.loaded event containing the current list of contacts when it starts. However, the backend can additionally send up to three types of events:

  • contact.created, when a new contact is created
  • contact.updated, when a contact is updated
  • contact.deleted, when a contact is deleted

The payload of each of those events has a contact property containing the contact on which the command was executed.

Based on this information, we can modify the dispatcher so it listens for those events and republishes them locally:

src/contacts/services/event-dispatcher.js

//Omitted snippet... 
export class ContactEventDispatcher { 
  //Omitted snippet... 
 
  activate() { 
    if (!this.connection) { 
      this.connection = io(environment.contactsUrl); 
 
      this.connecting = new Promise(resolve => { 
        this.connection.on('contacts.loaded', e => { 
          this.eventAggregator.publish('contacts.loaded', { 
            contacts: e.contacts.map(Contact.fromObject) 
          }); 
          resolve(); 
        }); 
      }); 
 
      this.connection.on('contact.created', e => {

        this.eventAggregator.publish('contact.created', {

          contact: Contact.fromObject(e.contact)

        });

      });


      this.connection.on('contact.updated', e => {

        this.eventAggregator.publish('contact.updated', {

          contact: Contact.fromObject(e.contact)

        });

      });


      this.connection.on('contact.deleted', e => {

        this.eventAggregator.publish('contact.deleted', {

          contact: Contact.fromObject(e.contact)

        });

      });
 
    } 
 
    return this.connecting; 
  } 
 
  //Omitted snippet... 
} 

Here, we add event handlers, so that, when the backend sends either a contact.created event, a contact.updated event, or a contact.deleted event, the impacted contact is transformed into a Contact object, and the event is republished on the application's event aggregator.

Once this is ready, we need to activate the event listener. We will do this in the contacts feature's configure function. However, the dispatcher uses the Contact class to transform the list of objects received from the backend into Contact instances when initiating the connection. Since the Contact class relies on the aurelia-validation plugin to be loaded, and since we can't be sure that the plugin is indeed loaded when our configure function is called, we can't use Contact here, otherwise an error could be thrown when initializing the validation rules of Contact. How can we do it, then?

The Aurelia framework configuration process supports post-configuration tasks. Such tasks are simply functions that will be called after all plugins and features are loaded, and can be added using the postTask method of the framework's configuration object, which is passed to the configure function:

src/contacts/index.js

import {Router} from 'aurelia-router'; 
import {ContactEventDispatcher} from './services/event-dispatcher'; 
 
export function configure(config) { 
  const router = config.container.get(Router); 
  router.addRoute({ route: 'contacts', name: 'contacts', moduleId: 'contacts/main', nav: true, title: 'Contacts' }); 
 
  config.postTask(() => {
const dispatcher = config.container.get(ContactEventDispatcher);

    return dispatcher.activate();
  }); 
} 

Here, we add a post-configuration task, which activates the dispatcher once all plugins and features have been loaded. Additionally, since post-configuration tasks support Promises, we can return the Promise returned by activate, so we are sure that the interactive connection with the backend is completed and that the initial contacts are loaded when the framework's bootstrapping process completes.

Adding notifications

At this point, our main component of contacts listens for server events, and dispatches them locally. However, we still don't do anything with those events. Let's add some notifications that tell the user when something happens on the server.

We will add a notification system that will let the user know every time the backend sends a change event. As such, we will use a library called humane.js, which can be found at http://wavded.github.io/humane-js/. You can install it by opening a console window in the project directory and by running the following command:

> npm install humane-js --save

Once it's completed, you must also let the bundler know about this library. In aurelia_project/aurelia.json, under build, then bundles, in the dependencies section of the bundle named vendor-bundle.js, add the following snippet:

{ 
  "name": "humane-js", 
  "path": "../node_modules/humane-js", 
  "main": "humane.min" 
}, 

In order to isolate usage of this library, we will create a custom element around it:

src/contacts/components/notifications.js

import {inject, noView} from 'aurelia-framework'; 
import {EventAggregator} from 'aurelia-event-aggregator'; 
import Humane from 'humane-js'; 
 
@noView 
@inject(EventAggregator, Humane) 
export class ContactNotifications { 
 
  constructor(events, humane) { 
    this.events = events; 
    this.humane = humane; 
  } 
 
  attached() { 
    this.subscriptions = [ 
      this.events.subscribe('contact.created', e => { 
        this.humane.log(`Contact '${e.contact.fullName}' was created.`); 
      }), 
      this.events.subscribe('contact.updated', e => { 
        this.humane.log(`Contact '${e.contact.fullName}' was updated.`); 
      }), 
      this.events.subscribe('contact.deleted', e => { 
        this.humane.log(`Contact '${e.contact.fullName}' was deleted.`); 
      }) 
    ]; 
  } 
 
  detached() { 
    this.subscriptions.forEach(s => s.dispose()); 
    this.subscriptions = null; 
  } 
} 

This custom element first requires an EventAggregator instance and a Humane object to be injected into its constructor. When it is attached to the DOM, it subscribes to the contact.created, contact.updated, and contact.deleted events to display proper notifications when they are published. It also stores the subscriptions returned by the subscribe method of EventAggregator calls in an array, so it is able to dispose those subscriptions when it is detached from the DOM.

In order to use this custom element, we need to modify the template of the feature's main component by adding a require statement and an instance of this element.

However, the main template is growing larger, so let's remove the inlineView decorator from the view-model class and move the template to its own file:

src/contacts/main.html

<template> 
  <require from="./components/notifications"></require>
<contact-notifications></contact-notifications> 
  <router-view></router-view> 
</template> 

Lastly, we need to add the stylesheet for one of themes of humane.js, so the notifications are correctly styled:

index.html

<!DOCTYPE html> 
<html> 
  <head> 
    <!-- Omitted snippet... --> 
    <link href="node_modules/humane-js/themes/flatty.css" rel="stylesheet"> 
  </head> 
  <body> 
    <!-- Omitted snippet... --> 
  </body> 
</html> 

If you run the application at this point and modify a contact, you'll see that the notification doesn't show. What did we miss?

Getting out of the pitfall

This is one tricky gotcha that I've experienced a couple of times now when integrating libraries with Aurelia. It is caused by the aurelia-app attribute being on the body element.

Indeed, some libraries add elements to the body when they are loaded. This is what humane.js does. When it is loaded, it creates a DOM subtree, which it will use as a container to display notifications, and appends it to the body.

However, when Aurelia's bootstrapping process ends and the application gets rendered, the content of the element hosting the aurelia-app attribute gets replaced by the rendered view of the app component. This means that the DOM element's humane.js will try to use to display notifications that won't be on the DOM anymore. Oops.

Fixing this is pretty simple. We need to move the aurelia-app attribute to another element, so the content of the body element won't be wiped out when our application is rendered:

index.html

<!DOCTYPE html> 
<html> 
  <head> 
    <!-- Omitted snippet... --> 
  </head> 
  <body> 
    <div aurelia-app="main"> 
      <!-- Omitted snippet... --> 
    </div> 
  </body> 
</html> 

Now, if you refresh your browser and then perform some action, such as updating a contact, you should see a notification being displayed for a couple of seconds at the top of the viewport.

Note

As a rule of thumb, I never put the aurelia-app attribute directly in the body. I learnt this lesson by spending too much time, on multiple occasions, trying to figure out why an external library I had integrated into my project didn't work.

Simulating a multi-user scenario

At this point, our application is able to notify the user when a change occurs on the server, even when this is done by another user. Let's test a multi-user scenario. To do this, the application must be run using something other than Aurelia's CLI because, at the time of writing, the browser sync feature interferes with our synchronization mechanism.

The simplest solution is to install the http-server node module, if you don't already have it installed, by running the following command:

> npm install -g http-server

Then you can build our application:

> au build

Once this command has completed, you can launch a plain HTTP server:

> http-server -o -c-1

You can then open the application in two browser windows and put them side by side. In one, perform actions such as creating a new contact or updating an existing one. You should see the notification pop up in both windows.

Using shared services

At the moment, our application is mostly stateless, since every route component loads its data from the server. There is no route component that depends on a global state, outside of its own scope.

However, sometimes an application needs to store a global state. This state is typically managed by some kind of service, which can either be propagated through components using data binding or injected into them using the dependency injection system, in which case the dependency is declared and controlled in the JS code, not in a template.

There are plenty of scenarios where locally storing the state is beneficial, or even required. It can allow the saving of bandwidth and reducing the number of calls to the backend. If you want to make your app available offline, you'll probably need to locally store a state at some point.

In this section, we will refactor our application by creating a service that will be shared among all route components and that will allow them to access the same local data. This service will act as a local data store, and will rely on the events published by the dispatcher we created in the previous section to both initialize its state and stay synchronized with the server's state.

Creating an in-memory store

We will start our refactoring by creating a new service that we'll call ContactStore:

src/contacts/services/store.js

import {inject} from 'aurelia-framework'; 
import {EventAggregator} from 'aurelia-event-aggregator';  
import {Contact} from '../models/contact'; 
 
@inject(EventAggregator) 
export class ContactStore { 
 
  contacts = []; 
 
  constructor(eventAggregator) { 
    this.eventAggregator = eventAggregator; 
  } 
 
  activate() { 
    this.subscriptions = []; 
  } 
 
  detached() { 
    this.subscriptions.forEach(s => s.dispose()); 
    this.subscriptions = null; 
  } 
 
  getById(id) { 
    const index = this.contacts.findIndex(c => c.id == id); 
    if (index < 0) { 
      return Promise.reject(); 
    } 
    return Promise.resolve(Contact.fromObject(this.contacts[index])); 
  } 
} 

This store first declares a contacts property, which is assigned an empty array. This array will contain the local list of contacts. Next, the class expects an EventAggregator instance to be injected into its constructor, which is then stored on the eventAggregator property.

The class then defines an activate method, which will subscribe to some events on the aggregator, and a deactivate method, which disposes of the subscriptions. This is the same pattern we implemented when we wrote the notifications component earlier.

The ContactStore also exposes a getById method, which expects a contact id as its argument, and which either returns a rejected Promise if the contact is not found or a Promise resolved using a copy of the contact if it is. This method will be used by some route components in place of the gateway's getById method, so it mimics its signature to minimize the amount of changes we have to do.

Now the activate method needs to have some event subscriptions added so it can react to them:

src/contacts/services/store.js

// Omitted snippet... 
export class ContactStore { 
  // Omitted snippet... 
 
  activate() { 
    this.subscriptions = [ 
      eventAggregator.subscribe('contacts.loaded', e => {

        this.contacts.splice(0);

        this.contacts.push.apply(this.contacts, e.contacts);

      }),

      eventAggregator.subscribe('contact.created', e => {

        const index = this.contacts.findIndex(c => c.id == e.contact.id);

        if (index < 0) {

          this.contacts.push(e.contact);

        }

      }),

      eventAggregator.subscribe('contact.updated', e => {

        const index = this.contacts.findIndex(c => c.id == e.contact.id);

        if (index >= 0) {

          Object.assign(this.contacts[index], e.contact);

        }

      }),

      eventAggregator.subscribe('contact.deleted', e => {

        const index = this.contacts.findIndex(c => c.id == e.contact.id);

        if (index >= 0) {

          this.contacts.splice(index, 1);

        }

      }), 
    ]; 
  } 
   
  // Omitted snippet... 
} 

Here, the activate method subscribes to the various events published by the dispatcher so it can keep its list of contacts up-to-date:

  • When it receives a contacts.loaded event, it resets the contacts array using the new list of contacts contained in the event's payload
  • When it receives a contact.created event, it first makes sure that the contact doesn't already exist in the array using its id and, if not, adds it
  • When it receives a contact.updated event, it retrieves the local copy of the updated contact still using its id and updates all of its properties
  • When it receives a contact.deleted event, it finds the contact's index in the array, always using its id, and splices it out

This store is now able retrieve a local copy of the list of contacts from the server, and then keep itself up-to-date.

Using the store

We can now modify all the route components that perform read operations so they use this store instead of the gateway. Let's walk through them.

First, the creation component doesn't need to change.

Next, the details, the edition, and the photo components must be modified. For each of them, we need to:

  1. Import the ContactStore class
  2. Add the ContactStore class to the inject decorator so it is injected in the constructor
  3. Add a store argument to the constructor
  4. In the constructor, assign the store argument to a store property
  5. In the activate method, replace the call to the getById method of gateway with a call to the store

Here's what the details component looks like after those changes:

src/contacts/components/details.js

import {inject} from 'aurelia-framework'; 
import {Router} from 'aurelia-router'; 
import {ContactStore} from '../services/store'; 
import {ContactGateway} from '../services/gateway'; 
 
@inject(ContactStore, ContactGateway, Router) 
export class ContactDetails { 
 
  constructor(store, gateway, router) { 
    this.store = store; 
    this.gateway = gateway; 
    this.router = router; 
  } 
 
  activate(params, config) { 
    return this.store.getById(params.id).then(contact => { 
      this.contact = contact; 
      config.navModel.setTitle(this.contact.fullName); 
    }); 
  } 
 
  tryDelete() { 
    if (confirm('Do you want to delete this contact?')) { 
      this.gateway.delete(this.contact.id) 
        .then(() => { this.router.navigateToRoute('contacts'); }); 
    } 
  } 
} 

Notice how the delete operation is still called on the gateway. Indeed, all write operations are still performed using the ContactGateway class. However, all read operations will now be performed using the ContactStore service, as it keeps a synchronized, local copy of the server's state.

As such, and lastly, the list component must also be modified. We need to:

  1. Replace the ContactGateway import for a ContactStore import
  2. Replace the dependency on the ContactGateway class with a dependency on the ContactStore class on the inject decorator
  3. Remove the contacts property declaration and initialization
  4. Replace the constructor's gateway argument with a store argument
  5. In the constructor, remove the assignation of the gateway property by assigning the store argument's contacts property to this.contacts
  6. Remove the activate callback method

The new list component is now stripped down to its minimum:

src/contacts/components/list.js

import {inject, computedFrom} from 'aurelia-framework'; 
import {ContactStore} from '../services/store'; 
 
@inject(ContactStore) 
export class ContactList { 
   
  constructor(store) { 
    this.contacts = store.contacts; 
  } 
} 

We can see here the state sharing at its core. The contacts property of store contains an array that is the actual state holder. It is this array that, being shared among components through the ContactStore instance, allows the same data to be accessed from the different screens. As such, this array should never be overwritten, only mutated, so Aurelia's binding system can work with it seamlessly.

However, we still need to activate the ContactStore instance somewhere, so it can start listening for change events. Let's do this in the feature's configure function, just before we activate the event dispatcher:

src/contacts/index.js

import {Router} from 'aurelia-router';  
import {ContactStore} from './services/store'; 
import {ContactEventDispatcher} from './services/event-dispatcher'; 
 
export function configure(config) { 
  const router = config.container.get(Router); 
  router.addRoute({ route: 'contacts', name: 'contacts', moduleId: 'contacts/main', nav: true, title: 'Contacts' }); 
 
  config.postTask(() => { 
    const store = config.container.get(ContactStore);

    store.activate(); 
 
    const dispatcher = config.container.get(ContactEventDispatcher); 
    return dispatcher.activate(); 
  }); 
} 

Here, we force the DI container to initialize the single ContactStore instance by retrieving it, then we simply activate it.

Lastly, we could go and delete the getAll and getById methods from the ContactGateway class, since they are not used anymore.

At this point, if you run the application, everything should still work as before.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.188.205.139