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:
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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 connecting
Promise
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 connecting
Promise
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 createdcontact.updated
, when a contact is updatedcontact.deleted
, when a contact is deletedThe 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 Promise
s, 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.
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?
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.
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.
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.
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:
contacts.loaded
event, it resets the contacts
array using the new list of contacts contained in the event's payloadcontact.created
event, it first makes sure that the contact doesn't already exist in the array using its id
and, if not, adds itcontact.updated
event, it retrieves the local copy of the updated contact still using its id
and updates all of its propertiescontact.deleted
event, it finds the contact's index in the array, always using its id
, and splices it outThis store is now able retrieve a local copy of the list of contacts from the server, and then keep itself up-to-date.
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:
ContactStore
classContactStore
class to the inject
decorator so it is injected in the constructorstore
argument to the constructorstore
argument to a store
propertyactivate
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:
ContactGateway
import for a ContactStore
importContactGateway
class with a dependency on the ContactStore
class on the inject
decoratorcontacts
property declaration and initializationgateway
argument with a store
argumentgateway
property by assigning the store
argument's contacts
property to this.contacts
activate
callback methodThe 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.
18.188.205.139