-
Notifications
You must be signed in to change notification settings - Fork 3
Observer patterns in Aurelia
**Note: Full code examples are contained in this repository with different branches for each pattern. All examples use TypeScript.
You want to listen for events in a child component. In this example, we'll take a simple app where there is a child component containing a list of people. In this child component, a particular person can be 'selected' by pressing a button and this event should trigger some action in the parent.
We're going to make the following list:
We have a simple model for a person:
person.ts
export interface Person {
firstName: string;
lastName: string;
}
The entry point for the application:
app.html
<template>
<require from="person-list"></require>
<div class="container">
<person-list people.bind="people"></person-list>
</div>
</template>
app.ts
import {bindable} from 'aurelia-framework';
import {Person} from './person';
export class App {
@bindable() people: Person[] = [
{firstName: 'John', lastName: 'Doe'},
{firstName: 'Joe', lastName: 'Bloggs'},
];
}
And finally a component for displaying lists of people:
person-list.html
<template>
<ul class="list-group">
<li class="list-group-item" repeat.for="person of people">
${person.firstName} ${person.lastName}
<button class="btn">Select</button>
</li>
</ul>
</template>
person-list.ts
import {bindable} from 'aurelia-framework';
import {Person} from './person';
export class PersonList {
@bindable() people: Person[];
}
Some of these will be unnecessarily complex for this particular example, but the techniques could be useful in more complex situations.
One option is to have a bindable selectedPerson
which we pass into the child component with two-way binding. Then when the button is pressed, the child component changes this variable. As two-way binding is used, this change will also happen in the parent where you can react to the change using a selectedPersonChanged
function. Any bindable variable in a view model has a corresponding [variable-name]Changed
function which is called when the variable changes.
Full code: here
The key pieces:
Add a bindable to app.ts with a corresponding listener for changes:
@bindable() selectedPerson: Person;
selectedPersonChanged(person) {
alert(`${person.firstName} ${person.lastName} selected`);
}
Two-way bind this to the child component in app.html:
<person-list people.bind="people" selected-person.two-way="selectedPerson"></person-list>
Add this bindable to child component with a corresponding method to trigger when the relevant button is pressed:
@bindable() selectedPerson: Person;
selectPerson(person) {
this.selectedPerson = person;
}
Add an event trigger for when a button is pressed in person-list.html:
<button class="btn" click.trigger="selectPerson(person)">Select</button>
(thanks to @atsu85)
Another option is passing the setter/callback function reference from parent to child component using .call
binding. Then the reference to the callback function that is defined in parent will be stored on child component and it can be used as normal function with the parameters specified by the function declaration in parent component.
Full code: here
The key pieces:
Add the setter/callback function that You want to be called by the child component:
selectPerson(person) {
alert(`${person.firstName} ${person.lastName} selected`);
}
Pass the callback function to the child component:
<person-list people.bind="people" select-person-callback.call="selectPerson($event)"></person-list>
Add bindable field for the function reference to child component with a corresponding method to call it when the relevant button is pressed:
@bindable() selectPersonCallback: (Person) => void;
selectPerson(person) {
this.selectPersonCallback(person);
}
Just like with the previous example using bindables, add an event trigger for when a button is pressed in person-list.html:
<button class="btn" click.trigger="selectPerson(person)">Select</button>
(thanks to @atsu85)
Another option is using Dependency Injection to inject shared state object to both (parent and child) components. Then the shared state object can be used as normal object to set property value in child component and to read (or even observe value changes) it in parent.
Full code: here
The key pieces:
Create a class for the shared state (so an instance of it can be created by Aurelia Dependency Injection Container)
import {Person} from './person';
export class SelectedPersonHolder {
person: Person;
}
Now inject the (singleton) instance to both parent ...
import {SelectedPersonHolder} from './SelectedPersonHolder';
import {autoinject} from 'aurelia-framework';
@autoinject
export class App {
...
constructor(private selectedPersonHolder: SelectedPersonHolder) {
}
}
...and child component, where it is used to change the shared state:
import {bindable, autoinject} from 'aurelia-framework';
import {SelectedPersonHolder} from './SelectedPersonHolder';
@autoinject
export class PersonList {
...
constructor(private selectedPersonHolder: SelectedPersonHolder) {
}
selectPerson(person) {
this.selectedPersonHolder.person = person;
}
}
Note: The instance must be created by Aurelia (not manually, for example
new SelectedPersonHolder()
), otherwise changes won't be detected by Aurelia!
Add the click event handler to the child component template to assigns new value to the shared state:
<button class="btn" click.delegate="selectPerson(person)">Select</button>
If in the parent component You only need to display the value set by child component there isn't much more to do, other than adding it to the html template:
selected: ${selectedPersonHolder.person.firstName} ${selectedPersonHolder.person.lastName}
But if You also want to execute custom code when the value is changed, You can subscribe to property change to receive the new (and old) value.
import {Person} from './person';
import {SelectedPersonHolder} from './SelectedPersonHolder';
import {autoinject, BindingEngine, Disposable} from 'aurelia-framework';
@autoinject
export class App {
...
observerDisposer: Disposable;
constructor(private selectedPersonHolder: SelectedPersonHolder, private bindingEngine: BindingEngine) {
this.observerDisposer = this.bindingEngine.propertyObserver(this.selectedPersonHolder, "person")
.subscribe((person: Person, oldValue: Person) => {
alert(`${person.firstName} ${person.lastName} selected`);
});
}
deactivate() {
// make sure to call it when component doesn't need the changes any more
this.observerDisposer.dispose();
}
}
Another option is to use the publish/subscribe pattern. Other blogs (like this one) describe Aurelia's event aggregator in more detail. The idea is that you have can publish events to some channel in one component (identified by a string - eg person:selected
) and then subscribe to that channel anywhere else in your application.
Full code: here
The key pieces:
After injecting the event aggregator into both parent and child, publish to the channel 'person:selected'
in the child:
selectPerson(person: Person) {
this.eventAggregator.publish('person:selected', person);
}
Subscribe to the same channel in the parent:
constructor(eventAggregator: EventAggregator) {
this.eventAggregator = eventAggregator;
this.eventAggregator.subscribe('person:selected', person => this.personSelected(person));
}
personSelected(person: Person) {
alert(`${person.firstName} ${person.lastName} selected`);
}
A third option is using signals. This post has a great comparison of this method with publish/subscribe. On of our concernx with publish/subscribe here is the lack of type safety - the compiler has no way of telling us if we subscribe to a channel with a function taking the wrong type. Signals are a way of addressing this.
Full code: here
The key pieces:
We create a Signal
class that we use for each type of event. Each instance of a signal object will be analogous to a channel (eg 'person:selected'
in publish/subscribe):
export class Signal<T> {
handlers: ((T) => any)[] = [];
add(f: (t: T) => any) {
this.handlers.push(f);
}
dispatch(t: T) {
this.handlers.forEach(f => f(t));
}
}
Handlers here is a list of functions to be called whenever something is dispatched to the signal. In our case, we add the function in the parent component as a handler for when we dispatch a person (the person selected) from the child component.
We create a new signal object in the child component for when a person is selected and then set up a dispatch event to be fired when the button is pressed:
personSelected: Signal<Person> = new Signal<Person>();
selectPerson(person: Person) {
this.personSelected.dispatch(person);
}
In the parent, we must first get a reference to the child view-model so that we can access this newly created signal. Then we add a function to the handlers for this signal so the parent can react to the event.
<person-list view-model.ref="personList" people.bind="people"></person-list>
import {PersonList} from './person-list';
export class App {
@bindable() people: Person[] = [
{firstName: 'John', lastName: 'Doe'},
{firstName: 'Joe', lastName: 'Bloggs'},
];
personList: PersonList;
attached() {
this.personList.personSelected.add(person => this.personSelected(person));
}
personSelected(person: Person) {
alert(`${person.firstName} ${person.lastName} selected`);
}
}
The advantage here is that if we accidentally had personSelected(person: string)
or some other non-Person
type, the compiler will catch this error. Similarly, when firing the event we ensure that the type of object being dispatched is Person
.