PWA Toolkit Logo

Building a PWA with Stencil: Routing and Forms



·

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

Throughout this series, we have been building a Progressive Web Application with Ionic and Stencil. We have covered the basics of Stencil and we have started to build out an application with it, and so far it looks like this:

PWA built with Stencil

We just have a single page that has a layout created using Ionic web components. In this tutorial, we are going to add an additional page and set up routing so that the user can navigate to it. We will discuss we can handle navigation using Stencil, and we will also be walking through how to set up a simple form. The purpose of this form will be to allow a user to add a new holding to their cryptocurrency portfolio.

Before We Get Started

As this is a continuation of a series, you will need to have completed the other tutorials first in order to follow along step-by-step. If you already understand the basics of Stencil and Ionic, you can skip the first two tutorials and start with: Rendering Layouts.

Creating a New Page component

Since we generated this project using the Ionic PWA Toolkit we already have a couple of page components generated for us (a component that we are using as a page is no different to any other component). We reused the app-home component to hold the home page for our own application, but instead of reusing the app-profile page that is included by default, we will be deleting that and creating our own from scratch.

Delete the src/components/app-profile folder

Once you have deleted that, we will create our own component by creating a new folder with the appropriate files.

Create a folder at src/components/app-add-holding and add the following files:

  • app-add-holding.scss
  • app-add-holding.tsx

Since we are not worrying about unit testing at this stage, it is not essential that we also add the app-add-holding.spec.ts file but you can if you want to. It isn’t enough to just create the folder and files, we also need to set up the component in stencil.config.js.

Modify stencil.config.js to reflect the following:

exports.config = {
  bundles: [
    { components: ['my-app', 'app-home'] },
    { components: ['app-add-holding'] },
    { components: ['lazy-img'] }
  ],
  collections: [
    { name: '@stencil/router' },
    { name: '@ionic/core' }
  ],
  serviceWorker: {
    swSrc: 'src/sw.js'
  },
  globalStyle: 'src/global/app.css'
};

exports.devServer = {
  root: 'www',
  watchGlob: '**/**'
};

In order to set up the component, we add it to the bundles array. In this case, we have just replaced the existing app-profile component with our new app-add-holding component. You may also notice that the Stencil router and all of the Ionic components are being included in the collections array.

Adding a Basic Layout

We will eventually be adding a form to our new page, but to begin with, we just want to set up some basic navigation. We are going to need to add some basic code to our new component so that we can navigate to it and see something on the screen.

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

import { Component } from '@stencil/core';

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

    render() { 

        return (
            <ion-page>
                <ion-header>
                  <ion-toolbar color="primary">
                    <ion-title>Add Holding</ion-title>
                  </ion-toolbar>
                </ion-header>

                <ion-content>

                </ion-content>

            </ion-page>
        );
    }

}

Setting up and Navigating to a Route

In order to be able to navigate to this new page using the Stencil router, we will need to set up a route for it. You will find all of the routes for the application defined in the root MyApp component at src/components/my-app/my-app.tsx. The render function for that will look something like this:

  render() {
    return (
      <ion-app>
        <main>
          <stencil-router>
            <stencil-route url='/' component='app-home' exact={true}>
            </stencil-route>

            <stencil-route url='/add-holding' component='app-add-holding'>
            </stencil-route>
          </stencil-router>
        </main>
      </ion-app>
    );
  }

Inside of the <stencil-router> we can define any routes we want using <stencil-route>. There are additional configurations we can use, but the basics are that we supply the route we want to use to url and the component we want to display when that route is hit to component. By adding the second route in the example above, the Stencil router will know to display the app-add-holding component when the /add-holding URL is hit.

Make sure to update the render function in src/components/my-app/my-app.tsx to resemble the example above.

Now that we have our route added, we need to add a way for the user to navigate to it. We are going to modify our home component to include a button in the header that will trigger this route when clicked.

Modify the <ion-header> section of the template in src/components/app-home/app-home.tsx to reflect the following:

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

We have added a button to the toolbar, similarly to how we would in a normal Ionic/Angular application. Notice that we are now using slot="end" to specify where in the toolbar the button should sit. We add our button and surround it with <stenicl-route-link>. We supply this component with a url, which is the route we want to go to when the button is clicked. Now when the user clicks that button, they will be taken to the AppAddHolding component.

We will also need to add a way for the user to get back to the home page once they have navigated to the AppAddHolding page. If you are used to Ionic/Angular then you may be unpleasantly surprised that this is not handled automatically. In an Ionic/Angular application, if a new page is pushed onto the navigation stack then a back button is added automatically to the pushed page.

This is perhaps another good example of some of the bells and whistles you miss out on by not using a framework. If we are just relying on Stencil with no framework, then it is much more of a manual process to handle navigation. We will need to add a button manually to our page and set up functionality that will navigate the user back to the home page.

Modify the <ion-header> section of the template in src/components/app-add-holding/app-add-holding.tsx to reflect the following:

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

We’ve used the exact same technique that we used to link to our add holding page – we just wrap the button in a <stencil-route-link> component, except this time the url that we supply is the root URL: /.

Although we will not be using it in this example as the navigation in our application is very simple, it is worth noting that it is also possible to navigate programmatically by passing RouterHistory into your components. For example, you can use methods like:

this.history.push('/route', {});

to push a new route onto a navigation stack, and:

this.history.pop('/route', {});

to navigate back. There are even methods available like:

this.history.goBack();

which will behave in the same way that the browser back button would. We will likely cover these concepts in the future, but in the meantime, you can find details about this in the documentation for the Stencil router.

Once you implement this example, another thing you may notice is the lack of screen transition animations. We faced a similar issue in the Ionic/VueJS series that I wrote. Again, this is something that we have out of the box with ionic-angular, but if we are foregoing feature packed frameworks this is another thing that we will have to handle ourselves.

Creating a Simple Form

The last thing we are going to take a look at in this tutorial is how to create a simple form using Stencil. In an Ionic/Angular application, we would typically use [(ngModel)] which can set up two-way binding to class members that we have defined. When the user modifies any input with an associated [(ngModel)], the class member would be immediately updated with the new value and be available to us to use.

Again, we are not using a framework so we don’t have features like this built in. Instead, we need to handle form input using standard Javascript features. Let’s implement the form in its entirety and then discuss how it works.

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

import { Component } from '@stencil/core';

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

    private cryptoCode: string;
    private displayCurrency: string;
    private amountHolding: number;

    addHolding(){

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

        console.log(holding);
        // todo: handle adding holding

    }

    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>
        );
    }

}

First of all, we have created three class members just like we usually would when setting up [(ngModel)] with Ionic/Angular:

    private cryptoCode: string;
    private displayCurrency: string;
    private amountHolding: number;

However, our form looks a little different:

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

Instead of adding an [(ngModel)] we set up a listener for the onInput event that will trigger the changeValue function. That function looks like this:

    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;
            }

        }
    }

We pass in the input event, and we check the name to see which input has been changed. We then update the appropriate variables with the value passed in from the event (i.e. the value that the user has entered). This is similar to what [(ngModel)] achieves, except that we aren’t also binding the value back to the input (we have one-way binding, not two-way binding). If we were to change the value programmatically it would not be updated in the template (there is no need for us to do that in this instance).

We also have an onClick listener set up for the Add Holding button that will call the addHolding() function:

    addHolding(){

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

        console.log(holding);
        // todo: handle adding holding

    }

All this is doing for now is dumping the values into an object, and then logging that out to the console. We will handle actually using this information in the next tutorial. Finally, let’s also just add a couple of styles to this component to make it look a little nicer.

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

app-add-holding {

    ion-footer {
        padding: 10px;
        font-size: 0.7em;
        color: #474747;
        background-color: #f6f6f6;
    }

    ion-content {

        .scroll-inner {
            padding: 10px !important;
        }


        button {
            width: 100%;
        }

    }

}

Once you have completed that change, the Add Holding page should now look like this:

Crypto application built with Stencil

Summary

The built-in route component that Stencil provides makes configuring navigation quite easy, and even though we need to do a bit of extra work when compared to Ionic/Angular it is still quite manageable. I think this tutorial in particular highlights the bells and whistles that Angular provides, and how that can make development easier, you just have to weigh that against the performance cost of using a framework. Keep in mind that the performance cost in most cases is more than acceptable – Angular was built for mobile optimised performance, so it is certainly not like it is some slow and clunky library.

We are getting pretty close to finishing the application now. In the next tutorial, we will focus on saving holdings to storage and fetching prices from an API.

What to watch next...