Recently, we’ve been covering a few different ways to handle state management in an Ionic and StencilJS application. This has included using simple singleton services and far more complex state management solutions like Redux.
If you have a background in Angular you may be used to using observables as part of managing state in an application, e.g. having a service that provides an observable that emits data changes. Observables are provided by the RxJS library which is included and used in Angular by default, so it is commonly used by Angular developers. RxJS is not included in StencilJS by default, but that doesn’t mean that we can’t use it if we want to - we just need to install it.
In this tutorial, we will be extending on the singleton service concept we have covered previously to include the use of observables. Similarly to the previous tutorial, we will be creating a service that has the responsibility of saving/loading data and providing it to the application. However, we will be modifying it to instead provide a class member variable that we can subscribe
to in order to get access to the data, as well as automatically receive any future updates to that data.
To do this, we will be making use of a partiuclar type of observable called a BehaviorSubject
.
Using a BehaviorSubject
We covered the concept of using a BehaviorSubject
a while ago specifically for Angular applications. The general concept is no different here, but let’s quickly recap what exactly a BehaviorSubject
is.
A BehaviorSubject
is a type of observable (e.g. a stream of data that we can subscribe
to). If you are not already familiar with the general concept of an observable, it might be worth doing a little bit of extra reading first. RxJS as a whole is quite complex, but the concept of an observable is reasonably straight-forward. You can create observables, or have one supplied to you, and that observable can emit data over time. Anything that is “subscribed” to that observable will get notified every time new data is emitted - just like your favourite YouTube channel.
I say that a BehaviorSubject
is a type of observable because it is a little different to a standard observable. We subscribe to a BehaviourSubject
just like we would a normal observable, but the benefit of a BehaviourSubject
for our purposes is that:
- It will always return a value, even if no data has been emitted from its stream yet
- When you subscribe to it, it will immediately return the last value that was emitted immediately (or the initial value if no data has been emitted yet)
We are going to use the BehaviorSubject
to provide the data that we want to access throughout the application. This will allow us to:
- Just load data once, or only when we need to
- Ensure that some valid value is always supplied to whatever is using it (even if a load has not finished yet)
- Instantly notify anything that is subscribed to the
BehaviorSubject
when the data changes
1. Installing RxJS in a StencilJS Application
Now that we have a general understanding of observables, and more specifically a BehaviorSubject
, let’s start building out our solution. All you will need to begin with is to have either an existing StencilJS project, or to create a new one. It does not matter whether this is a generic StencilJS application or an Ionic/StencilJS application.
To install RxJS, you will need to run the following command:
npm install rxjs
2. Creating a Service that uses Observables
For the general concept behind using a singleton service in a StencilJS application, I would recommend first reading the previous tutorial:
In this tutorial, we will mostly be focusing on the incorpoation of the BehaviorSubject
.
Create a file at src/services/my-service.ts and add the following:
import { BehaviorSubject } from "rxjs";
import { MyDataType } from "../interfaces/my-data";
class MyServiceController {
public myData: BehaviorSubject<MyDataType[]> = new BehaviorSubject<MyDataType[]>([]);
private testStorage: MyDataType[] = []; // Just for testing
constructor() {}
load(): void {
this.myData.next(this.testStorage);
}
addData(data): void {
this.testStorage.push(data);
this.myData.next(this.testStorage);
}
}
export const MyService = new MyServiceController();
This is similar in style to the simple singleton service created in the previous tutorial, with the obvious inclusion of the BehaviorSubject
. We create a new BehaviorSubject
like this:
public myData: BehaviorSubject<MyDataType[]> = new BehaviorSubject<MyDataType[]>([]);
This sets up a new class member variable called myData
and it has a type of BehaviorSubject<MyDataType[]>
. This means that it will be a BehaviorSubject
that supplies values of the type MyDataType[]
(i.e. an array of elements of the type MyDataType
). The definition of this type is not important, but if you are curious, this is the interface I have defined at /interfaces/my-data
:
export interface MyDataType {
title: string;
description: "string;"
}
We assign a new BehaviorSubject
to our class member by instantiating an instance with the new
keyword, and we supply an empty array as an initial value: ([])
. The BehaviorSubject
will always supply some value to anything that subscribes to it, even if it has not emitted any data yet. By supplying this initial blank array, we know that we will get a blank array back if no data has been loaded/emitted yet.
Since this is a public class member variable, we will be able to access this observable directly wherever we include this service.
In this example, we are just using another variable called testStorage
to act as our storage mechanism, but you could replace this with something else. You might want to pull values in from the browsers local storage, or perhaps you might be loading data from some additional service like Firebase.
However we want to load that data, in our load()
method we will just need to do whatever is required to load the data, and then call:
// handle loading data
// emit data
this.myData.next(/* supply loaded data here */);
This will cause our BehaviorSubject
to emit whatever data is supplied to next
, and anything that is subscribed to myData
will receive that data. Our addData
method is similar, we would just do something like this:
addData(data): void {
// handle adding new data to storage mechanism
// emit data
this.myData.next(/* supply new data here */);
}
First, we would have to handle doing whatever is required to store that new data wherever we want it, and then once again call the next
method with our new data to emit it to any subscribers.
3. Subscribing to Observables
We still need to “consume” the data/state that is being provided, but doing that with our observable is quite simple. You could subscribe
to the observable anywhere you like, but typically this would happen somewhere like the componentDidLoad
lifecycle hook that is triggered automatically:
componentDidLoad() {
MyService.myData.subscribe(data => {
console.log("Received data: ", data);
});
}
Every time new data is emitted by the BehaviorSubject
this function will run:
(data) => {
console.log('Received data: ', data);
};
Using our testStorage
example, this would create a result like this if we were to add some new data three times:
Received data: []
Received data: [{...}]
Received data: (2) [{...}, {...}]
Typically we would want to do more than just log the data out, we might also want to make use of this data in our template. Let’s take a look at a more complete implementation:
import { Component, State, h } from '@stencil/core';
import { MyService } from '../../services/my-service';
import { MyDataType } from '../../interfaces/my-data';
@Component({
tag: 'app-home',
styleUrl: 'app-home.css',
})
export class AppHome {
@State() items: MyDataType[] = [];
componentDidLoad() {
MyService.myData.subscribe((data) => {
console.log('Received data: ', data);
this.items = [...data];
});
}
testAddData() {
MyService.addData({
title: 'test',
description: "'test',"
});
}
render() {
return [
<ion-header>
<ion-toolbar color="primary">
<ion-title>Home</ion-title>
</ion-toolbar>
</ion-header>,
<ion-content class="ion-padding">
<ion-button onClick={() => this.testAddData()}>Test</ion-button>
<ion-list>
{this.items.map((item) => (
<ion-item>
<ion-label>{item.title}</ion-label>
</ion-item>
))}
</ion-list>
</ion-content>,
];
}
}
This example provides a button that can be used to add new test data, and it will also display all of the current data in an <ion-list>
. Every time the Test button is clicked, a new item should pop up in the list automatically.
An important thing to note about this example is that we are doing this:
MyService.myData.subscribe((data) => {
this.items = [...data];
});
instead of this:
MyService.myData.subscribe((data) => {
this.items = data;
});
Instead of just updating this.items
with the array returned from our observable, we instead create a new array and use the spread operator to pull out all of the elements inside of the data
array and add them to this new array. When using StencilJS there are two important things to keep in mind in order to make sure your template updates correctly:
- Use the
@State
decorator on any variables that you want to update the template - Assign new values instead of updating old values - mutating arrays or objects will not cause template updates in StencilJS
If you do not do this, then the render()
function will not be triggered and your view/template will not update in response to data changes. These steps are only required for variables that you want to update the template.
Summary
Wherever possible, and especially with StencilJS, I like to avoid using additional libraries and use plain JavaScript concepts wherever I can in applications. This cuts down on often unnecessary overhead and JavaScript/TypeScript now provides so much out of the box that we can make use of.
However, I have spent a lot of time developing Angular applications, and have found observables to be infinitely useful/efficient. Although it is good to attempt to cut down on waste and not include unnecessary libraries, RxJS is one of those libraries that can provide a lot of value and it is likely something I will use in most of my StencilJS applications.