PWA Toolkit Logo

Building a PWA with Stencil: Storage and Services



·

Part 1: An Introduction to Stencil
Part 2: Project Structure and Syntax
Part 3: Rendering Layouts
Part 4: Routing and Forms
Part 5: Storage and Services (this tutorial)

Over the past couple of weeks, we have been working our way through building a Progressive Web Application (PWA) with Ionic web components and Stencil. We have been building a clone of the cryptoPWA application that I built a little while ago using Ionic/Angular. We have mostly finished the main parts of the application, the biggest feature that we have remaining to complete is the ability to save added holdings and fetch the current price for those holdings.

In this tutorial, we will be building and integrating a service to store data locally, and another service to handle fetching the price of holdings from an external API. This tutorial will highlight some important differences between using a framework like Angular with Ionic when building a PWA versus not using a framework like we are doing in this series.

I originally planned to finish off the rest of the application in this part but it ended up being a little more involved than I initially anticipated. Instead, of finishing off everything completely, we will just be focusing on the logic and we will handle addressing UI/UX concerns in another tutorial.

Providers/Services in Stencil

When using Ionic/Angular we generally create providers using @Injectable to handle all of the “work” for our application. That might be saving data, fetching data from an API, or performing some calculations. These providers will generally be singletons, which basically means there is a single instance of the provider for the entire application. If you add some data to your provider, you could use that provider in any of your pages and access the same data (rather than having a new instance of the provider for each page which would then have its own set of data).

We can create a similar style of providers/services in our Stencil application, but since we aren’t using a framework it will just be through the use of a normal ES6 class. We create a file that exports some class that will be our service, and then we import that wherever we want to use it. An issue that we will run into is that these services won’t be singletons. You can set up a singleton structure with plain JavaScript as well, but it is going to be easier for us to just slightly restructure the way we retrieve our data for the application.

Creating a Storage Service

We’ve run into this situation a few times throughout this series, and I am going to keep highlighting it. We are not using a framework, so when it comes to something like data storage it is up to us to decide how to implement it. I quite like the Ionic/Angular Storage API, and in the series of tutorials I did on using Ionic with VueJS I recreated a storage service based on how the built-in Ionic/Angular storage service works.

We are going to use that same storage service in this application. Using VueJS with Ionic is quite similar to using Stencil with Ionic – because VueJS is a very lightweight framework that doesn’t have a lot of features built in. Because of that, we are going to be able to just copy and paste the VueJS service into this Stencil application.

How the storage service works is actually quite interesting, but I won’t be discussing how or why this service works, and how it differs to the original Ionic/Angular implementation. If that is something that you are interested in, you should read the beginning of this tutorial where I cover it in depth. However, it is important to understand that this service will automatically choose the best storage mechanism available out of normal browser local storage, Web SQL, IndexedDB, or SQLite. It relies on a library called localForage to do this, and if you want to enable support for SQLite you will also need to include the localforage-cordovasqlitedriver package.

Install the following packages in your project:

npm install localforage --save
npm install localforage-cordovasqlitedriver --save

We are going to create a new folder called services that will hold all of the classes for the services we create.

Create a file at src/services/storage.ts and add the following code:

import LocalForage from 'localforage';
import CordovaSQLiteDriver from 'localforage-cordovasqlitedriver';

export default class Storage {

    dbPromise;

    constructor(){

        this.dbPromise = new Promise((resolve, reject) => {

            let db;

            let config = {
                name: '_cryptostorage',
                storeName: '_cryptokv',
                driverOrder: ['sqlite', 'indexeddb', 'websql', 'localstorage']
            }

            LocalForage.defineDriver(CordovaSQLiteDriver).then(() => {
                db = LocalForage.createInstance(config);
            })
                .then(() => db.setDriver(this.getDriverOrder(config.driverOrder)))
                .then(() => {
                    resolve(db);
                })
                .catch(reason => reject(reason));

        });

    }

    ready(){
        return this.dbPromise;
    }

    getDriverOrder(driverOrder){

        return driverOrder.map((driver) => {

            switch(driver){
                case 'sqlite':
                    return CordovaSQLiteDriver._driver;
                case 'indexeddb':
                    return LocalForage.INDEXEDDB;
                case 'websql':
                    return LocalForage.WEBSQL;
                case 'localstorage':
                    return LocalForage.LOCALSTORAGE;
            }

        });

    }

    get(key){
        return this.dbPromise.then(db => db.getItem(key));
    }

    set(key, value){
        return this.dbPromise.then(db => db.setItem(key, value));
    }

    remove(key){
        return this.dbPromise.then(db => db.removeItem(key));
    }

    clear(){
        return this.dbPromise.then(db => db.clear());
    }

}

With this service defined, we will be able to use it anywhere in our application. This will allow us to use methods like:

this.storage.set('key', value);

and

this.storage.get('key');

to easily store and retrieve data.

Creating a Holdings Service

Now that we have our storage service set up, we are going to use it to save our holdings into storage when they are added. We also want to be able to retrieve our holdings from storage and to fetch the current prices for the holdings from an API. To do this, we are going to create another service.

Create a file at src/services/holdings.ts and add the following code:

import Storage from './storage';

interface Holding {
    crypto: string,
    currency: string,
    amount: number,
    value?: number
}

export default class Holdings {

    public storage: Storage = new Storage();

    constructor() {

    }

    addHolding(holding: Holding): void {

        this.storage.get('cryptoHoldings').then((holdings) => {

            if(holdings !== null){
                holdings.push(holding);
            } else {
                holdings = [holding];
            }

            this.storage.set('cryptoHoldings', holdings);

        });

    }

    removeHolding(holding): void {

        this.storage.get('cryptoHoldings').then((holdings) => {

            holdings.splice(holdings.indexOf(holding), 1);

            this.storage.set('cryptoHoldings', holdings);

        });

    }

    getHoldings(): Promise<any> {

        return new Promise((resolve, reject) => {

            this.storage.get('cryptoHoldings').then((holdings) => {

                if(holdings === null){
                    resolve([]);
                } else {

                    // Retrieve the price for each holding

                    let requests = [];

                    holdings.forEach((holding) => {
                        let request = this.fetchPrice(holding);
                        requests.push(request);
                    });

                    Promise.all(requests).then((results) => {

                        console.log(results);

                        holdings.map((holding, index) => {
                            holding.value = results[index].ticker.price;
                        });

                        resolve(holdings);

                    }).catch((err) => {
                        reject(err);
                    });

                }

            });

        });

    }

    fetchPrice(holding): Promise<any> {

        return fetch('https://api.cryptonator.com/api/ticker/' + holding.crypto + '-' + holding.currency).then((res) => res.json());

    }

}

This service is similar in style to the original provider created in the Ionic/Angular application, but there are some key differences. In the Ionic/Angular PWA, we were storing all of the holdings data directly on the provider and then accessing that data from the pages. Since we aren’t using a singleton anymore, we have instead modified the functions so that they return the data to our pages through a promise.

You may also notice that since we aren’t using Angular’s system of dependency injection, we are just instantiating a new instance of the services we are using:

public storage: Storage = new Storage();

Most of the service is the same in spirit to its predecessor – we are saving data to storage, retrieving data from storage, and fetching data from an API. One key difference is that we are using the Fetch API and Promises now, instead of the built-in Angular HTTP library and Observables. You can install whatever library you like to perform HTTP requests, and you can also install the RxJS package to add observables if you like. The reason we are using fetch is because it is a cool built-in Web API, so there is no need to install anything and we have a reasonably nice API to work with – we can easily call the json() method to convert the response into a nice object that we can use. The Fetch API does have quite good support from browsers, but keep in mind that it does not have universal support.

Saving Data

We’ve got both of our services set up, so now we just need to make use of them. We are going to start by modifying our Add Holding component so that the data entered into the form is actually saved. We’ve already done most of the work in the previous tutorial, we just need to take the supplied data and pass it along to our holdings service.

Modify src/components/app-add-holding/app-add-holding.tsx to reflect the following:

import { Component, Prop } from '@stencil/core';
import { RouterHistory } from '@stencil/router';
import Holdings from '../../services/holdings';

@Component({
    tag: 'app-add-holding',
    styleUrl: 'app-add-holding.scss'
})
export class AppAddHolding {

    private holdingsService: Holdings = new Holdings();
    private cryptoUnavailable: boolean = false;
    private checkingValidity: boolean = false;
    private noConnection: boolean = false;
    private cryptoCode: string;
    private displayCurrency: string;
    private amountHolding: number;

    @Prop() history: RouterHistory;

    addHolding(){

        this.cryptoUnavailable = false;
        this.noConnection = false;
        this.checkingValidity = true;

        let holding = {
            crypto: this.cryptoCode,
            currency: this.displayCurrency,
            amount: this.amountHolding || 0
        };

        this.holdingsService.fetchPrice(holding).then((result) => {

            console.log(result);

            this.checkingValidity = false;

            if(result.success){
                this.holdingsService.addHolding(holding);
                this.history.goBack();
            } else {
                this.cryptoUnavailable = true;
            }

        }).catch((err) => {
            this.noConnection = true;
            this.checkingValidity = false;
        });

    }

    changeValue(ev){

        let value = ev.target.value;

        switch(ev.target.name){

            case 'cryptoCode': {
                this.cryptoCode = value;
                break;
            }

            case 'displayCurrency': {
                this.displayCurrency = value;
            }

            case 'amountHolding': {
                this.amountHolding = value;
            }

        }
    }

    render() { 

        return (
            <ion-page>
                <ion-header>
                  <ion-toolbar color="primary">
                    <ion-buttons slot="start">
                        <stencil-route-link url='/'>
                            <ion-button>
                                <ion-icon slot="icon-only" name="arrow-back"></ion-icon>
                            </ion-button>
                        </stencil-route-link>
                    </ion-buttons>
                    <ion-title>Add Holding</ion-title>
                  </ion-toolbar>
                </ion-header>

                <ion-content>

                    <div class="message">

                        <p>To add a holding you will need to supply the appropriate symbol for the cryptocurrency, and the symbol for the currency you would like to display the values in.</p>

                        <p><strong>Note:</strong> Listed prices are estimated. Rates may vary significantly across different exchanges.</p>

                    </div>

                    <ion-list>

                        <ion-item>
                            <ion-label stacked>Crypto Code</ion-label>
                            <ion-input name="cryptoCode" onInput={(ev) => this.changeValue(ev)} placeholder="(e.g. BTC, LTC, ETH)" type="text"></ion-input>
                        </ion-item>

                        <ion-item>
                            <ion-label stacked>Display Currency Code</ion-label>
                            <ion-input onInput={(ev) => this.changeValue(ev)} name="displayCurrency" placeholder="(e.g. USD, CAD, AUD)" type="text"></ion-input>
                        </ion-item>

                        <ion-item>
                            <ion-label stacked>Amount Holding</ion-label>
                            <ion-input onInput={(ev) => this.changeValue(ev)} name="amountHolding" type="number"></ion-input>
                        </ion-item>

                    </ion-list>   

                    <ion-button onClick={() => this.addHolding()}>Add Holding</ion-button>

                </ion-content>

                <ion-footer>

                    <p><strong>Note:</strong> This web application allows you to track your Cryptocurrency without creating an account. This means that all data is stored locally, and may be permanently deleted without warning.</p>

                </ion-footer>
            </ion-page>
        );
    }

}

The most interesting part of this file is the following:

    @Prop() history: RouterHistory;

    addHolding(){

        this.cryptoUnavailable = false;
        this.noConnection = false;
        this.checkingValidity = true;

        let holding = {
            crypto: this.cryptoCode,
            currency: this.displayCurrency,
            amount: this.amountHolding || 0
        };

        this.holdingsService.fetchPrice(holding).then((result) => {

            console.log(result);

            this.checkingValidity = false;

            if(result.success){
                this.holdingsService.addHolding(holding);
                this.history.goBack();
            } else {
                this.cryptoUnavailable = true;
            }

        }).catch((err) => {
            this.noConnection = true;
            this.checkingValidity = false;
        });

    }

There are a couple of things happening here. In the Ionic/Angular application, we can use the NavController to programmatically pop the Add Holding page off of the navigation stack once the user has submitted the form (since we want them to be taken back to the home page at that point). We want to do a similar thing here, but we don’t have a NavController. Fortunately, the Ionic PWA Toolkit does ship with its router component, and we can use that to help us navigate here. If we pass in RouterHistory as a @Prop() we can then use that to control navigation. In this case, we use the goBack() method to take the user back to the home page.

Another interesting part of this code is that we need to check that the holding actually exists on the API. Before we add the holding, we first attempt to use fetchPrice with the holding that is being added. If this request fails, then we know that the holding does not exist and that it should not be added. If it does exist, then we just use our holdings service to add it.

Loading the Data

The last thing we need to do is load the holdings data we have stored into our home page component. This is just a simple case of making a call to our holdings service.

Modify src/components/app-home/app-home.tsx to reflect the following:

import { Component, State } from '@stencil/core';
import Holdings from '../../services/holdings';

interface Holding {
  crypto: string,
  currency: string,
  amount: number,
  value?: number
}

@Component({
  tag: 'app-home',
  styleUrl: 'app-home.scss'
})
export class AppHome {

  @State() holdings: Holding[] = [];

  private holdingsService = new Holdings();    

  componentWillLoad(){

    this.holdingsService.getHoldings().then((holdings) => {
      this.holdings = holdings;
    });

  }

  renderWelcomeMessage(){

    return (

      <div>
        {!this.holdings.length ? (
          <div class="message"><p><strong>cryptoPWA</strong> is a <strong>P</strong>rogressive <strong>W</strong>eb <strong>A</strong>pplication that allows you to keep track of the approximate worth of your cryptocurency portfolio.</p>

            <p>A PWA is like a normal application from the app store, but you can access it directly through the web. You may also add this page to your home screen to launch it like your other applications.</p>

            <p>No account required, just hit the button below to start tracking your coins in whatever currency you wish!</p>

            <stencil-route-link url='/add-holding'><ion-button color="primary">Add Coins</ion-button></stencil-route-link>

          </div>
        ) : (
          null
        )}
      </div>
    );

  }

  render() {

    return (
      <ion-page>

        <ion-header>
          <ion-toolbar color="primary">
            <ion-buttons slot="end">
              <stencil-route-link url='/add-holding'>
                <ion-button>
                  <ion-icon slot="icon-only" name="add"></ion-icon>
                </ion-button>
              </stencil-route-link>
            </ion-buttons>
            <ion-title>
              cryptoPWA
            </ion-title>
          </ion-toolbar>
        </ion-header>

        <ion-content>

          {this.renderWelcomeMessage()}

          <ion-list no-lines>

            {this.holdings.map((holding) => 

                <ion-item-sliding>

                  <ion-item class="holding">
                    <p><strong>{holding.crypto}/{holding.currency}</strong></p>
                    <p class="amount"><strong>Coins:</strong> {holding.amount} <strong>Value:</strong> {holding.value}</p>
                    <p class="value">{holding.amount * holding.value}</p>
                  </ion-item>

                  <ion-item-options>
                    <ion-button class="delete-button" icon-only color="danger"><ion-icon name="trash"></ion-icon></ion-button>
                  </ion-item-options>

                </ion-item-sliding>

            )}

          </ion-list>

        </ion-content>

        <ion-footer>

          <p><strong>Disclaimer:</strong> Do not use this application to make investment decisions. Displayed prices may not reflect actual prices.</p>

        </ion-footer>

      </ion-page>
    );
  } 

}

We have imported and instantiated the holdings service, but the important part of the code above is:

  componentWillLoad(){

    this.holdingsService.getHoldings().then((holdings) => {
      this.holdings = holdings;
    });

  }

Whenever the component is loaded, we make a call to getHoldings and then set this.holdings to the result. An important thing to keep in when working with Stencil is that although your view will be updated when a @State is changed, the view will not be updated if you are modifying the data within an array or object. This means that if we had instead done something like this:

this.holdings.push(holding);

our view would not have updated. Instead, you need to make sure that you are creating a new array rather than modifying an existing one. You can read more about this here.

Summary

We still have a couple of things to finish up in this application, but the work we have done so far effectively highlights some of the key differences between using Ionic/Angular to build a PWA and using Stencil to build a PWA. The draw of Stencil is that it is just pure web components and plain Javascript, and that can lead to extremely optimized performance. The downside (depending on your perspective) is that you miss out on a lot of the built-in bells and whistles that accompanies a framework. Some of the “bells and whistles” we have missed throughout this series are:

  • Built-in Storage API
  • Dependency injection system allowing for singleton providers
  • Navigation / Screen transition animations
  • Built-in HTTP library with more universal support

The main point I have been trying to make clear about this series is that there is no preferred option, and depending on your perspective you could easily consider something that some might consider a “pro” to actually be a “con” or vice versa.

If I were to make some kind of recommendation, I would say to use Ionic/Angular by default – especially if you are more on the beginner/intermediate side in terms of skill level. With Ionic/Angular you have a lot of the hard choices made for you and everything you need available out of the box. You are, in a way, giving up flexibility for guidance. If you are more advanced, then I think you would be in a better position to determine which approach might suit you better – but either approach is still very feasible.

What to watch next...