Creating Reactive, Loosely Coupled Apps with Aurelia and Flux - Episode 2
Recently we featured Tomasz Frydrychewicz, the Aurelia community member who authored the
aurelia-flux plugin. Today, we're happy to bring you the second article in his series where he'll take you on a reactive, application-building adventure. Read on to dive into the details.
In the previous episode I covered different parts of aurelia-flux. This time, I would like to get you through creating a fully working reactive Aurelia application. Let us start with the simple Todo List application and we'll see where we can get to.
The Code
The described project is available for checkout here.
Let's Get to Work
If you don't know how to get started with Aurelia, there is a great introduction available on Aurelia's web site. From now on I assume that you already know how to setup a basic Aurelia project, build and run it.
With that in place, let's start with installing aurelia-flux:
jspm install aurelia-flux
and enabling it in our brand new application, in main.js.
export function configure(aurelia) { aurelia.use .standardConfiguration() .developmentLogging() .plugin('aurelia-flux'); aurelia.start().then(a => a.setRoot()); }
First Screen
Although your application will have only one screen, we will still configure a router to start serving the todo list. Open app.js and add the list view to the router configuration.
import 'bootstrap'; import 'bootstrap/css/bootstrap.css!'; export class App { configureRouter(config, router){ config.title = 'ToDo List'; config.map([ { route: [''], name: 'list', moduleId: './list', nav: true, title:'List' } ]); this.router = router; } }
To finalize the application routing configuration, let's check that app.html contains all the necessary components. You should have that file already generated, if you created your application using the guide from the Aurelia site.
<template> <require from='./nav-bar'></require> <nav-bar router.</nav-bar> <div class="container"> <router-view></router-view> </div> </template>
Having that done, you can start creating the app. Let's start with the list.html view. In order to add new todos, you need an input box and a button.
<template> <div class="row"> <div class="col-md-12 text-center"> <h1>ToDo List</h1> </div> </div> <hr /> <div class="row"> <div class="col-md-10"> <input type="text" class="form-control input-lg" ref="text" placeholder="What would you like to do?"> </div> <div class="col-md-2"> <button class="btn btn-lg btn-success btn-block" click.Add</button> </div> </div> </template>
So far, so good. You've come to the point where you have to add some logic to the application. This is the moment when you actually start adding reactive behavior to your todo list. To accompany our first view, we need a view model (app.js) with an
addItem(...) method accepting a string.
import {inject} from 'aurelia-framework'; import {Dispatcher} from 'aurelia-flux'; @inject(Dispatcher) export class List { constructor(dispatcher) { this.dispatcher = dispatcher; } addItem(text) { let newItemText = (text || '').trim(); if(newItemText === '') { return; } this.dispatcher.dispatch('list.addItem', newItemText); } }
Well... that wasn't much of an effort, was it? So what's so special about that code? What actually makes the code reactive and dependencies loosely coupled is the
list.addItem action being dispatched, instead of a method being invoked. When you dispatch an action, you don't have any hard dependency, you don't really care about who listens and reacts to that action.
The List, Your First Reactive Component
Your application can already collect todos from the input box and ask (it doesn't really know who, but it's always kind to ask) someone to add it to the list. Now you are going to create that someone who will react to the requested action. As reactive programming alleviates feature driven development, let's create a new todo-list folder for the component that will hold todos list. Inside, there will be three (all your reactive components, will consist not only of the view and the view-model, but also of the Store, responsible for handling actions and exposing data) files: todo-list.html, todo-list.js and todo-list.store.js. The view will be a standard component view.
<template> > </template>
This view is built from a series of div blocks, one for each item in
todoListStore.items (you'll see the Store in a couple of sentences). Each block contains a completion mark, which, when clicked, will trigger
$parent.toggleCompleted(...) and
${item.text}. Yet again, that is not anything new or unexpected. Here's the accompanying view-model.
import {customElement, inject} from 'aurelia-framework'; import {Dispatcher} from 'aurelia-flux'; import {TodoListStore} from './todo-list.store'; @customElement("todo-list") @inject(Dispatcher, TodoListStore) export class TodoList { constructor(dispatcher, todoListStore) { this.dispatcher = dispatcher; this.todoListStore = todoListStore; } toggleCompleted(item) { if(item.completed === false) { this.dispatcher.dispatch('list.completeItem', item.id); } else { this.dispatcher.dispatch('list.undoCompleteItem', item.id); } } }
Just like the first view-model, the TodoList doesn't have any hard dependencies. Instead of that, it just kindly asks to either complete the item or undo its completion and hopes that there will be someone to hear it. It also exposes its Store
todoListStore to the view.
So what's in the Store?
import {handle} from 'aurelia-flux'; export class TodoListStore { _items = new Map(); get items() { return this._items; } @handle('list.addItem') addItem(action, text) { let newItem = new ListItem(text); this._items.set(newItem.id, newItem); } @handle('list.completeItem') completeItem(action, id) { if(this._items.has(id)) { this._items.get(id).completed = true; } } @handle('list.undoCompleteItem') undoCompleteItem(action, id) { if(this._items.has(id)) { this._items.get(id).completed = false; } } } export class ListItem { constructor(text) { this.id = (+new Date() + Math.floor(Math.random() * 999999)).toString(36); this.text = text; this.completed = false; } }
As you can see, the Store is the one who listens. It actually knows just three words:
list.addItem,
list.completeItem and
list.undoCompleteItem. To allow it to hear, use the
@handle(...) decorator from the aurelia-flux library. When applied to a method, it tells the dispatcher to invoke that method, whenever a given action is being dispatched. Let's take a closer look at the
addItem(...) method.
@handle('list.addItem') addItem(action, text) { let newItem = new ListItem(text); this._items.set(newItem.id, newItem); }
The first line tells the flux dispatcher that this method is interested in handling the
list.addItem action. An important details is that every handler method will expect an action name on its first parameter. The following parameters will match those passed to the
dispatch(...) method. The
action parameter will contain the actual action name that triggered this method (in this case it'll be
list.addItem). You may ask, why do I need that information if I already have it in
@handle(...) decorator? That is because, apart from a specific name, the decorator accepts wildcards:
* for any string,
? for one character. For example:
@handle('*') ... @handle('list.*') ... @handle('list?.add*') ...
Having that said, you may not always be able to tell which action you're currently handling. That is why actually dispatching action's name is always available on the first parameter.
The rest of the
addItem(...) method should be rather straightforward.
Having all those files set, you're ready to add the
todo-list component to the main view. Below the input box div block add a new div block with the
todo-list element.
<template> <require from="todo-list/todo-list"></require> ... <hr /> <div class="row"> <div class="col-md-12"> <todo-list></todo-list> </div> </div> ... </template>
From now on,
TodoListStore will handle adding new items, completing them, undoing the completion and will expose the list to the
todo-list component.
Measuring Can Be Fun
Who doesn't like statistics? Let's assume that you'd like to add a new functionality to the application and start collecting some statistics about the todos you collect. At first, just for a second, imagine that you're not creating a reactive application. Most probably, in order to start collecting new, statistical data the current code base would have to be modified. That is definitely not a happy place. Luckily, the application is already reactive.
As previously, start with creating a component folder todo-stats with three files in it: todo-stats.html, todo-stats.js and todo-stats.store.js. The view presents simple data.
<template> <div class="panel panel-default"> <div class="panel-heading"> <h3 class="panel-title">Statistics</h3> </div> <div class="panel-body"> <table class="table"> <tr> <td>Items</td> <td>${stats.items}</td> </tr> <tr> <td>Completed</td> <td>${stats.completed}</td> </tr> <tr> <td>Not completed</td> <td>${stats.uncompleted}</td> </tr> <tr> <td>Words</td> <td>${stats.words}</td> </tr> <tr> <td>Characters</td> <td>${stats.characters}</td> </tr> </table> </div> </div> </template>
The view-model couldn't be any simpler.
import {customElement, inject} from 'aurelia-framework'; import {TodoStatsStore} from './todo-stats.store'; @customElement('todo-stats') @inject(TodoStatsStore) export class TodoStats { constructor(todoStatsStore) { this.stats = todoStatsStore.stats; } }
And now, for the Store. If the application is going to be truly decoupled, how does the
todo-stats component know that a new item has been collected by the
todo-list component? It just has to hear about that. As you remember, the main view says
list.addItem on an Add button click. Just make your
TodoStatsStore hear and handle the same action. The same goes for the
list.completeItem and
list.undoCompleteItem actions. Finally, let's assume that you would like to assure that the
todo-list component will handle all those actions before the stats component does it. With the aurelia-flux dispatcher you get the
@waitFor(...) decorator, which allows you to build such ordered processing declaratively.
import {handle, waitFor} from 'aurelia-flux'; import {TodoListStore} from 'todo-list/todo-list.store'; export class TodoStatsStore { stats = { items: 0, completed: 0, uncompleted: 0, words: 0, characters: 0 }; @handle('list.addItem') @waitFor(TodoListStore) newItem(action, text) { this.stats.items++; this.stats.uncompleted++; this.stats.words += text.split(' ').length; this.stats.characters += text.length; } @handle('list.completeItem') @waitFor(TodoListStore) itemCompleted(action, id) { this.stats.completed++; this.stats.uncompleted--; } @handle('list.undoCompleteItem') @waitFor(TodoListStore) itemUncompleted(action, id) { this.stats.completed--; this.stats.uncompleted++; } }
Wait a second! That's not loosely coupled. It depends on an existing type - you probably thought. Well, that is not totally true. For unit testing purposes, instead of having the real
TodoListStore injected, you can inject whatever object you want. The aurelia-flux dispatcher will determine that this type didn't take part in dispatching the given action and will resolve it automatically to prevent you from being locked.
The only thing left is to add the newly created
todo-stats component to the main view.
<template> <require from="todo-list/todo-list"></require> <require from="todo-stats/todo-stats"></require> ... <hr /> <div class="row"> <div class="col-md-8"> <todo-list></todo-list> </div> <div class="col-md-4"> <div class="row"> <div class="col-xs-12"> <todo-stats></todo-stats> </div> </div> </div> </div> ... </template>
Hash Tag aurelia-flux
Who doesn't like hash tags? Everybody does :), so why don't you have them in your Todo List app? Every time you start thinking about a new reactive component, always consider it an independent, self-sufficient being, containing just what is needed to accomplish its task. In this case, what you need is a Store that will parse every new added todo item, look for hash tags and collect them along with the item's id. Your first thought might be to react to the
list.addItem action. Unfortunately, it doesn't carry the item's id, as it is being dispatched before an item was actually created. What can you do about that? The dispatcher can help you here, with its capability of queuing an action during dispatching another one. Just enrich
TodoListStore (todo-list/todo-list.store.js) with the ability of saying "Hey, I've just added a new item. Isn't that awesome?!"
import {inject} from 'aurelia-framework'; import {handle, Dispatcher} from 'aurelia-flux'; @inject(Dispatcher) export class TodoListStore { _items = new Map(); constructor(dispatcher) { this.dispatcher = dispatcher; } get items() { return this._items; } @handle('list.addItem') addItem(action, text) { let newItem = new ListItem(text); this._items.set(newItem.id, newItem); this.dispatcher.dispatch('list.itemAdded', newItem); } ... }
Now you have something you can react to, so lay the first stone and create the folder and the three files for your
todo-item-tags component.
The view will be used to display tags for each of the todos list items.
<template> <div if. Tags: <span repeat. <span class="label ${$parent.isItemCompleted ? 'label-default' : 'label-primary'}"> ${tag} </span> </span> </div> </template>
The view-model will expose the
TodoItemTagsStore data to the view. As the
todo-item-tags component is going to be used for each item, it needs a bindable property to store the item's id. It would also be awesome to change the component's styling when a corresponding item changes its completion state. Managing that in the Store may not be the best idea, as that is actually not part of its planned functionality. Let's stop for a second and think, what do we call a Store? Does it have to inherit from any special class, does it have to follow any particular convention? No. In fact the only thing that distinguishes it is its behavior. It hears and reacts. Can a view-model play the same role? Yes, of course. In your case the view model will have to react to
list.completeItem and
list.undoCompleteItem.
import {customElement, inject, bindable} from 'aurelia-framework'; import {handle} from 'aurelia-flux'; import {TodoItemTagsStore} from './todo-item-tags.store'; @customElement("todo-item-tags") @inject(TodoItemTagsStore) export class TodoItemTags { @bindable itemId; constructor(todoItemTagsStore) { this.todoItemTagsStore = todoItemTagsStore; this.isItemCompleted = false; } get tags() { return this.todoItemTagsStore.tags.get(this.itemId); } @handle('list.completeItem') itemCompleted(action, id) { if(this.itemId === id) { this.isItemCompleted = true; } } @handle('list.undoCompleteItem') undoItemCompleted(action, id) { if(this.itemId === id) { this.isItemCompleted = false; } } }
Now to the Store: the
todo-list component is already capable of saying that it has collected a new item and you know how to hear about it. You also know that a dispatcher can queue upcoming actions. Add a pinch of business logic and voila, you're done!
import {inject} from 'aurelia-framework'; import {Dispatcher, handle} from 'aurelia-flux'; @inject(Dispatcher) export class TodoItemTagsStore { _tags = new Map(); constructor(dispatcher) { this.dispatcher = dispatcher; } get tags() { return this._tags; } @handle('list.itemAdded') newItem(action, item) { let itemTags = item.text.match(/\#[^ $]+/g); if(itemTags !== null) { for(let tag of itemTags) { this.dispatcher.dispatch('tags.addTag', tag, item.id); } } } @handle('tags.addTag') addTag(action, tag, itemId) { tag = (tag || '').trim().toLowerCase().substring(1) if(this.tags.has(itemId) === false) { this.tags.set(itemId, []); } if(this.tags.get(itemId).indexOf(tag) === -1) { this.tags.get(itemId).push(tag); } } }
Finally, the last piece of the puzzle: connect
todo-item-tags with the
todo-list component (todo-list/todo-list.html) and give it a try.
<template> <require from="todo-item-tags/todo-item-tags"></require> <todo-item-tags item-id.</todo-item-tags> </div> </div> </template>
Summary
Creating reactive applications with aurelia-flux is not only fun but also gives you the opportunity to create a truly decoupled system. I hope you enjoyed this journey and that it's just the beginning of your reactive adventure.
