If you’re new to state management or you’re familiar with it and not using Aurelia Store already (you should), today we are going to be looking at how you can integrate Aurelia Store into your Aurelia applications and make the life of your development team and yourself a lot less stressful.
A lot of state management articles will wheel out the TV and VCR player on the tray table with wheels and default to the cliche shopping cart or todo application example to show you how to work with state management.
Look, they’re great simple examples showing you how to work with state management, but they are quite far removed from a large-scale application which could be comprised of hundreds of actions and troves of data going to and from the server.
Unless you’re building a todo application or basic shopping cart, these examples are not going to answer all of your questions nor reflect a real application with a real team working on it.
The truth of the matter is, real-world applications (especially for large products) can get big. I worked on an Aurelia application that had upwards of 40 components, not-to-mention value converters, attribute. a stack load of modals, routed sections and an impressive amount of singleton classes.
What problems does state management solve, and do I even need it?
State management isn’t something you always need, but if you’re working with a growing collection of data (from an API or user-entered) and you’re sharing it across your application, state management is unavoidable or you’ll end up with singleton soup.
For a long time using Aurelia, I actually avoided using state management, even though other solutions predate Aurelia Store such as MobX and Redux. The problem that most other state management solutions share is they require serious “buy in” to use them, they have a cost.
Fortunately, Aurelia Store aligns well with the Aurelia mantra of “just write code” so it doesn’t strictly enforce a specific way of working other than the basics like not directly mutating state and registering actions.
The biggest problem state management addresses in Javascript is: assign by reference. Every variable and value you reference is passed by reference, meaning it can be overwritten or accessed from anywhere in your application without any kind of visible trail which can be disastrous.
When you opt-in to state management, you’re explicitly choosing to update and read data from a centralised store via an abstraction. Good state management prevents you from accidentally overriding a value in the store without leaving a trail (still possible, but more difficult).
One example of where state management shines is Aurelia’s binding system. Picture this scenario, you have a form with 4 inputs and those inputs are using value.bind
referencing properties on some object or class. When those values update, the object values also update, form input value bindings are two-way
by default.
Now, imagine that object is being shared across your app and you have an array of users. The form allows you to update a user and everywhere sees the change immediately (pass by reference). Now imagine an invalid value is entered or something breaks, how do you revert it? You can’t (unless you have a copy of the object).
If you want to know when the value was updated, how do you do that? In Aurelia, you’ll need to use the propertyObserver
API or simplify your app and use @observable
to observe changes to values and act accordingly. It results in a lot of additional and potentially performance draining code that becomes a maintenance nightmare over time.
Aurelia Store features
- A reactive subscription observation system, so when changes are made to the store you can subscribe to the state itself and get the updated changes anywhere in your application. Think when a user logs in and out when a collection of products is loaded.
- State is not directly mutated, actions are used to update the state and leave a history you can inspect using Redux Dev Tools as well as undo or replay.
- Supports middleware. Allows you to integrate code that runs at various points of the state lifecycle, allowing you to filter values or check permissions.
- Aurelia Store gives you a clearer view of your data structures and separates the data layer from the view and view-models (easier to test, easier to refactor).
- Allows you to have safe and fast cross-component communication, meaning you no longer need to abuse the Event Aggregator or Router to pass data around.
- Easy to debug using the Redux Dev Tools which the plugin completely supports. A browser plugin for visually seeing actions and state as it happens, as well as call actions.
- Supports caching out-of-the-box, allows you to cache your data in localStorage and create offline/poor internet connection capable applications.
Structuring an Aurelia app for Aurelia Store
Unlike existing state management solutions, Aurelia Store doesn’t require you to structure your application in a specific way. This is both good and bad because it means you can choose a structure and go down a particular path only to find you chose the wrong architect.
Below is the approach I take in my Aurelia applications using a store, I will create a folder called store
in my src
directory.
Please keep in mind that I use TypeScript, so the below examples and explanations will be written in TypeScript and vanilla Javascript differs slightly.
src
├── app.html
├── app.ts
├── ...
├── store
│ ├── actions
│ │ ├── auth.ts
│ │ ├── user.ts
│ ├── store.ts
│ ├── state.ts
└── ...
The actions
folder contains files which contain functions (actions) that are exported and can be imported where needed. The auth.ts
example might have a login
and logout
method setting and unsetting the currently authentication user.
The user.ts
might have actions related to the user like getting content specific to their account, getting an avatar or updating their profile.
The store.ts
file is interesting because I actually import the Store itself and get a reference to it via the Aurelia Dependency Injection container. Here is what the inside of my store.ts
in new Aurelia applications I build looks like these days:
import { Container } from 'aurelia-framework';
import { Store } from 'aurelia-store';
import { State } from './state';
const store: Store<State> = Container.instance.get(Store);
export default store;
The reason I do this is so I do not have to follow the official examples where the Store is injected into every view-model you want to register actions from within. It was tedious and I wanted an easier way. It also allows me to actually register actions from within the action files themselves instead of view-models, which makes more sense to me.
If you want to manually inject the store into your view-models when needed, the official documentation and examples encourage this approach. This is just another option.
The state.ts
file has an object which is the application state structure itself. A basic application using the above example of a structure might look like this:
export interface State {
auth: {
loggedIn: boolean;
token: string;
refreshToken: string;
};
user: {
id: string;
username: string;
avatar: string;
profileFields: any;
};
}
export const initialState: State = {
auth: {
loggedIn: false,
token: '',
refreshToken: ''
},
user: {
id: '',
username: '',
avatar: '',
profileFields: {}
}
};
Configuring the plugin
We now have the state configured, so you’ll want to pass it as an argument to the aurelia-store
plugin registration inside of your main.ts
file inside of the exported configure
method.
import { Aurelia } from 'aurelia-framework'
import { initialState } from './store/state'; // exported initialState object
export function configure(aurelia: Aurelia) {
aurelia.use
.standardConfiguration()
.feature('resources');
...
aurelia.use.plugin('aurelia-store', { initialState });
aurelia.start().then(() => aurelia.setRoot());
}
And now we get into the actions themselves…
We glimpsed over actions earlier because to write them you really need your state in place first. Actions are what mutate the state values and you can actually do quite a lot with them. On a basic level, the first argument is the state object itself and you can pass parameters (which are subsequent arguments).
Actions can be sync or async. This means you can do things like make API requests from within actions and make your application asynchronously call and wait for the returned promise to resolve or reject. Let’s look at our fictitious auth.ts
actions file.
import store from 'store/store';
import { State } from 'store/state';
export function login(state: State, token: string, refreshToken: string) {
const newState = Object.assign({}, state);
newState.auth.loggedIn = true;
newState.auth.token = token;
newState.auth.refreshToken = refreshToken;
return newState;
}
export function logout(state: State) {
const newState = Object.assign({}, state);
newState.auth.loggedIn = false;
newState.auth.token = '';
newState.auth.refreshToken = '';
return newState;
}
store.registerAction('login', login);
store.registerAction('logout', logout);
If you have already read the Aurelia Store documentation (and you should), then you would know that actions should NEVER mutate the passed in state property directly and should always be shallow cloned using something like Object.assign
or the spread operator { ...state }
to make a copy of it.
You will also notice our login
action takes the state like all actions do, but has two other arguments for the token and refreshToken. When the action is dispatched, these can be passed which I’ll show you next.
Actions should always return a copy of the state, which then does the work of update the state object itself. Never just assigning new properties to state, because otherwise, you encounter the pass by reference issue we talked about earlier which is bad as there will be no trail of your updates (hard for testing and debugging).
Similarly, the users.ts
actions file might have an async function in it…
import store from 'store/store';
import { State } from 'store/state';
// A fictitious API service you might have
import { Api } from 'services/api';
// Use Aurelia DI to get the api because we are not inside of a view-model
const api: Api = Container.instance.get(Api);
export async function getAvatar(state: State) {
const newState = Object.assign({}, state);
try {
// An API function that accepts the current user ID
// notice we reference the user object to get the ID?
const avatarImage = await api.fetchUserAvatar(newState.user.id);
// Store the avatar
newState.user.avatar = avatarImage;
} catch (e) {
console.error(e);
}
return newState;
}
store.registerAction('getAvatar', getAvatar);
Triggering actions
We now have our store setup and configured. Now picture you have a login page with basic username and email fields, you hit a login button and it goes off to the server.
import { autoinject } from 'aurelia-dependency-injection';
import { dispatchify, Store } from 'aurelia-store';
import { State } from 'store/state';
import { login } from 'store/actions/auth';
@autoinject()
export class Login {
public state: State;
private subscription: Subscription;
constructor(private store: Store<State>) { }
bind() {
this.subscription = this.store.state.subscribe((state) => this.state = state);
}
unbind() {
this.subscription.unsubscribe();
}
doLogin() {
api.login(username, password)
.then(result => result.json())
.then(auth => {
dispatchify(login)(auth.token, auth.refreshToken);
});
}
}
I am using the dispatchify
method so I don’t have to manually inject the store into the view-model itself and dispatch the function that way. Worth acknowledging is when you dispatch a function, you pass the function itself and not the name.
The dispatchify
method returns a function which is the action itself, which allows you to pass values to its arguments. In our case, we are mocking a login method that calls an API and then if successful, will store the token
and refreshToken
values.
Async/await actions
We showcased a pretty basic example of dispatching the login action to set the token
and refreshToken
in the state. Now, let’s showcase how powerful asynchronous actions are, by calling our get user avatar action.
I have been asked this on a few occasions by people interested in the Aurelia Store plugin: is it okay to make API requests and request data from within actions? Absolutely. The fact actions support returning promises makes them the ideal candidate for encapsulating everything related to your data.
You will notice the code looks very much the same as the example above.
import { autoinject } from 'aurelia-dependency-injection';
import { dispatchify, Store } from 'aurelia-store';
import { State } from 'store/state';
import { login } from 'store/actions/user';
@autoinject()
export class User {
public state: State;
private subscription: Subscription;
constructor(private store: Store<State>) { }
bind() {
this.subscription = this.store.state.subscribe((state) => this.state = state);
}
unbind() {
this.subscription.unsubscribe();
}
async activate() {
await dispatchify(getAvatar)();
// If successful, the avatar will be available on this.state.user.avatar
// which can then be referenced in the view
}
}
Using getters and computedFrom
for sanity
Eventually, you will realise that in your views your referenced state objects and values can be pretty daunting to look at as well as type. You can use computed getters to create shortcuts to values in your store.
Using the above login example, we’ll create a getter to simplify accessing the auth property in our state.
import { autoinject } from 'aurelia-dependency-injection';
import { computedFrom } from 'aurelia-framework';
import { dispatchify, Store } from 'aurelia-store';
import { State } from 'store/state';
import { login } from 'store/actions/auth';
@autoinject()
export class Login {
public state: State;
private subscription: Subscription;
constructor(private store: Store<State>) { }
bind() {
this.subscription = this.store.state.subscribe((state) => this.state = state);
}
unbind() {
this.subscription.unsubscribe();
}
@computedFrom('state.auth') {
// Allows you to use auth.loggedIn and so on in your views
return this.state.auth;
}
doLogin() {
api.login(username, password)
.then(result => result.json())
.then(auth => {
dispatchify(login)(auth.token, auth.refreshToken);
});
}
}
My approach differs from the official documentation
It is worth pointing out that my approach differs greatly from the official Aurelia Store documentation and examples in the GitHub repository. I am not saying that this is the way you should do it, I am just showing you how I do it and what works for me.
The approach you see above is the same approach I take for all new Aurelia projects using the store. It allows me to neatly organise my actions into bite-sized modules and it just feels very clean.
This is only the start
We didn’t get too in-depth into how you can use the Aurelia Store plugin, because the official documentation does a great job of that already, which you can read here and if you haven’t, I highly recommend you should.
In a separate future article, I will show you how to use Aurelia Store with server-side rendering and implement features such as default state and offline capability.
How can you write tests for the actions, while the store requires the Container to be initialized? I mean test for the logic inside the action methods.