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 the remaining elements in future tutorials.

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 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.

Since originally writing this tutorial, I have created a much more in-depth tutorial that covers using services in StencilJS, you can check it out here: Using Services/Providers to Share Data in a StencilJS 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 a similar storage service in this application, although I have updated it somewhat since originally writing this article. We are mostly just going to focus on the implementation here, if you would like more of an explanation please read this article: Using the Capacitor Storage API for Storing Data. This approach does assume that you are using Capacitor.

Install the following packages in your project:

npm install --save @capacitor/core @capacitor/cli

Initialise Capacitor with the following command:

npx cap init

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 and folder at src/services/storage.ts and add the following code:

import { Plugins } from "@capacitor/core";

const { Storage } = Plugins;

export async function set(key: string, value: any): Promise<void> {
  await Storage.set({
    key: key,
    value: JSON.stringify(value)
  });
}

export async function get(key: string): Promise<any> {
  const item = await Storage.get({ key: key });
  return JSON.parse(item.value);
}

export async function remove(key: string): Promise<void> {
  await Storage.remove({
    key: key
  });
}

This is a little different to the singleton services we might typically create as discussed in this tutorial as we are actually just exporting three separate functions: set, get, and remove. We will be able to use these functions to store and retrieve data in a platform agnostic way thanks to Capacitor.

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 { get, set, remove } from "./storage";

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

class HoldingsController {
  async addHolding(holding: Holding): Promise<void> {
    let holdings = (await get("cryptoHoldings")) || [];
    holdings.push(holding);

    await set("cryptoHoldings", holdings);
  }

  async removeHolding(holding): Promise<void> {
    let holdings = await get("cryptoHoldings");
    holdings.splice(holdings.indexOf(holding), 1);

    await set("cryptoHoldings", holdings);
  }

  async getHoldings(): Promise<any> {
    let holdings = await get("cryptoHoldings");

    if (holdings === null) {
      return [];
    } else {
      // Retrieve the price for each holding

      let requests = [];

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

      try {
        let results = await Promise.all(requests);
        holdings.map((holding, index) => {
          holding.value = results[index].ticker.price;
        });

        return holdings;
      } catch (err) {
        console.log(err);
        return false;
      }
    }
  }

  async fetchPrice(holding): Promise<any> {
    const response = await fetch(
      "https://api.cryptonator.com/api/ticker/" + holding.crypto + "-" + holding.currency
    );

    return await response.json();
  }
}

export const Holdings = new HoldingsController();

This service is similar in style to the original provider created in the Ionic/Angular application. You may also notice that since we aren’t using Angular’s system of dependency injection, we are just directly importing and using the classes/functions that we import.

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.

For much more information on using the Fetch API, check out this tutorial: HTTP Requests in StencilJS with the Fetch API.

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, h } from "@stencil/core";
import { Holdings } from "../../services/holdings";

@Component({
  tag: "app-add-holding",
  styleUrl: "app-add-holding.css"
})
export class AppAddHolding {
  private cryptoCode: string;
  private displayCurrency: string;
  private amountHolding: number;

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

    let result = await Holdings.fetchPrice(holding);
    console.log(result);

    if (result.success) {
      await Holdings.addHolding(holding);
    }

    const navCtrl = document.querySelector("ion-router");
    navCtrl.back();
  }

  changeValue(ev) {
    const 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-header>
        <ion-toolbar color="primary">
          <ion-buttons slot="start">
            <ion-back-button defaultHref="/"></ion-back-button>
          </ion-buttons>
          <ion-title>Add Holding</ion-title>
        </ion-toolbar>
      </ion-header>,

      <ion-content class="ion-padding">
        <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 position="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 position="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 position="stacked">Amount Holding</ion-label>
            <ion-input
              onInput={ev => this.changeValue(ev)}
              name="amountHolding"
              type="number"
            ></ion-input>
          </ion-item>
        </ion-list>

        <ion-button expand="full" 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>
    ];
  }
}

Notice that we are using a querySelector to grab a reference to the ion-router here. If you are unfamiliar with this, check out this video: Using Ionic Controllers with Web Components (StencilJS).

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, h } 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.css"
})
export class AppHome {
  @State() holdings: Holding[] = [];

  componentDidLoad() {
    const router = document.querySelector("ion-router");

    // Refresh data every time view is entered
    router.addEventListener("ionRouteDidChange", async () => {
      const holdings = await Holdings.getHoldings();
      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>

            <ion-button href="/add-holding" color="primary">
              Add Coins
            </ion-button>
          </div>
        ) : null}
      </div>
    );
  }

  render() {
    return [
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>cryptoPWA</ion-title>
          <ion-buttons slot="end">
            <ion-button href="/add-holding" routerDirection="forward">
              <ion-icon slot="icon-only" name="add"></ion-icon>
            </ion-button>
          </ion-buttons>
        </ion-toolbar>
      </ion-header>,

      <ion-content class="ion-padding">
        {this.renderWelcomeMessage()}
        <ion-list lines="none">
          {this.holdings.map(holding => (
            <ion-item-sliding>
              <ion-item class="holding">
                <ion-label>
                  <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-label>
              </ion-item>

              <ion-item-options>
                <ion-item-option color="danger">
                  <ion-icon slot="icon-only" name="trash"></ion-icon>
                </ion-item-option>
              </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>
    ];
  }
}

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

  componentDidLoad() {
    const router = document.querySelector("ion-router");

    // Refresh data every time view is entered
    router.addEventListener("ionRouteDidChange", async () => {
      const holdings = await Holdings.getHoldings();
      this.holdings = [...holdings];
    });
  }

Whenever the route is changed, 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. One benefit of StencilJS 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.

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.

Check out my latest videos: