PWA Toolkit Logo

Building a PWA with Stencil: Rendering Layouts



·

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

In Part 3 of this series on building a Progressive Web Application with Ionic and Stencil, we will be finally be getting into the practical side of things. We will generate a new application using the Ionic PWA Toolkit, and we will focus on creating the basic layout for our application.

The application that we will be building is going to be a clone of the cryptoPWA application that I created a few weeks ago as a PWA using Ionic/Angular. This was also a multi-part tutorial series, you can check it out here if you like. Again, this series is tailored to people who are already familiar with Ionic/Angular. So, by recreating the same application it should serve as a good learning experience, as we will be able to make comparisons between the two.

NOTE: In order to use Ionic with Stencil, we need to use Ionic 4. Ionic 4 provides all of its components as web components, unlike previous versions which are specifically Angular components. At the time of writing this, Ionic 4 has not officially been released and we will be relying on an early test release of Ionic 4. Keep in mind that some features in Ionic 4 may not be complete.

At the end of this tutorial, our layout will look like this:

PWA built with Stencil

Generating a PWA with the Ionic PWA Toolkit

To get started, we are going to generate a new Stencil project using the Ionic PWA Toolkit. If you haven’t read the previous tutorials, the PWA toolkit is basically just a starter Stencil project with a bunch of useful features for creating a PWA with Ionic included.

Clone the Ionic PWA Toolkit into a new project:

git clone https://github.com/ionic-team/ionic-pwa-toolkit.git crypto-pwa

Make it your working directory:

cd crypto-pwa

Install the dependencies:

npm install

Run the dev build:

npm run dev

You should now be able to see the default application in your browser, and anytime you make a change to the code it will be refreshed in the browser.

Rendering a Basic Layout with Stencil

We are going to be working entirely on the app-home component in this tutorial. You will find it in src/components/app-home, and it will contain three files:

  • app-home.scss
  • app-home.spec.ts
  • app-home.tsx

This is more or less the same as an Angular component. The difference here is that the app-home.spec.ts unit test file is included by default (if you don’t know how to unit test you can just ignore this file for now), and the app-home.tsx file contains both the exported class and the template so we don’t have any .html file.

We will start by setting up a very basic layout, and we will add to it throughout this tutorial. If we take a look at the app-home.tsx file now, it will look like this:

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

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

  render() {
    return (
      <ion-page class='show-page'>
        <ion-header md-height='56px'>
          <ion-toolbar color='primary'>
            <ion-title>Ionic PWA Toolkit</ion-title>
          </ion-toolbar>
        </ion-header>

        <ion-content>
          <p>
            Welcome to the Ionic PWA Toolkit.
            You can use this starter to build entire PWAs all with
            web components using Stencil and ionic/core! Check out the readme for everything that comes in this starter out of the box and
            Check out our docs on <a href='https://stenciljs.com'>stenciljs.com</a> to get started.
          </p>

          <stencil-route-link url='/profile/stencil'>
            <ion-button>
              Profile page
            </ion-button>
          </stencil-route-link>
        </ion-content>
      </ion-page>
    );
  }
}

We’ve already discussed most of what is going on here in the previous tutorial. The render() function returns the template that will be displayed to the user. In this case, the Ionic web components are being used to create the page. This looks the same as any Ionic/Angular page you would normally create, except that we have to manually wrap it in the <ion-page> component.

An important thing to note about the render function is that it can only return one top-level element. In the above example, the <ion-page> wraps everything else, so that is the top level element that is returned. However, if <ion-page> did not wrap everything, we might try to do something like this:

    <ion-header md-height='56px'>
      <ion-toolbar color='primary'>
        <ion-title>Ionic PWA Toolkit</ion-title>
      </ion-toolbar>
    </ion-header>

    <ion-content>
      <p>
        Welcome to the Ionic PWA Toolkit.
        You can use this starter to build entire PWAs all with
        web components using Stencil and ionic/core! Check out the readme for everything that comes in this starter out of the box and
        Check out our docs on <a href='https://stenciljs.com'>stenciljs.com</a> to get started.
      </p>

      <stencil-route-link url='/profile/stencil'>
        <ion-button>
          Profile page
        </ion-button>
      </stencil-route-link>
    </ion-content>

This will not work because two top-level elements are being returned: <ion-header> and <ion-content>. If you do run into a situation where you want to return more than one top-level element, you can do so by having the render function return an array:

  render() {
    return ([

        <div>
            <p>First</p>
        </div>,

        <div>
            <p>Second</p>
        </div>,

        <div>
            <p>Third</p>
        </div>

    ]);
  }

Notice the use of an array [], where each top-level element is separated by a comma. Now, let’s modify the app-home component with our own template.

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

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

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

  render() {

    return (
      <ion-page>

        <ion-header>
          <ion-toolbar color="primary">
            <ion-title>
              cryptoPWA
            </ion-title>
          </ion-toolbar>
        </ion-header>

        <ion-content>

          <ion-list no-lines>

          </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 will be adding more as we go, but this is our basic layout. We just have a simple header and footer, with a (currently empty) list in the content area.

Adding Data

Eventually, we want to loop through all of the cryptocurrency holdings we have set up and display them in the list. We also want to display a conditional message on the screen if the user currently does not have any holdings added.

Before we do that, we need some data to work with. We aren’t going to worry about allowing users to add holdings through a form and then saving that data just yet, for now, we just want some dummy data to work with. We are going to set up some holdings data with @State because this data will change over time (and we need our template to update when it does).

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

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

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

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

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

  componentWillLoad(){

    // just set up some dummy data for now
    this.holdings = [
      {
        crypto: 'BTC',
        currency: 'USD',
        amount: 0.1,
        value: 11588.32892
      },
      {
        crypto: 'ETH',
        currency: 'USD',
        amount: 2,
        value: 1032.23421
      },
      {
        crypto: 'LTC',
        currency: 'USD',
        amount: 4,
        value: 153.2343242
      }
    ]

  }

  render() {

    return (
      <ion-page>

        <ion-header>
          <ion-toolbar color="primary">
            <ion-title>
              cryptoPWA
            </ion-title>
          </ion-toolbar>
        </ion-header>

        <ion-content>

          <ion-list no-lines>

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

}

Notice that we are now importing State from @stencil/core, we need to do this in order to use @State. We then have our holdings set up as follows:

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

We define holdings and we give it a type of Holding[]. This means that holdings will expect an array of items that are of type Holding. We define this type in an interface above:

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

Adding interfaces/types isn’t necessary, but it does help improve the quality of your code – we can be sure now that the holdings array will definitely contain the type of data we are expecting.

Finally, we use the componentWillLoad lifecycle hook to set up the data before the template is rendered:

  componentWillLoad(){

    // just set up some dummy data for now
    this.holdings = [
      {
        crypto: 'BTC',
        currency: 'USD',
        amount: 0.1,
        value: 11588.32892
      },
      {
        crypto: 'ETH',
        currency: 'USD',
        amount: 2,
        value: 1032.23421
      },
      {
        crypto: 'LTC',
        currency: 'USD',
        amount: 4,
        value: 153.2343242
      }
    ]

  }

Although this data is static for now, we have something to work with for the rest of the tutorial.

Displaying Parts of a Template Conditionally

In the previous tutorial, we discussed how we can easily return different templates based on a condition, e.g:

render() {
  if (this.name) {
    return ( <div>Hello {this.name}</div> )
  } else {
    return ( <div>Hello, World</div> )
  }
}

But on a more complex scale, this method doesn’t work all that well. If we have a big template with lots of conditions, we don’t want to supply 10 variations of the same template.

What we can do is offload sections of the template to other functions. For example, we could instead do something like this:

render(){

    return (
        <div>Hello, {this.getName()}</div>
    );

}

We are going to use this method to conditionally render our message on the screen that should only display when no holdings are in the holdings array.

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

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

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

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

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

  componentWillLoad(){

    // just set up some dummy data for now
    this.holdings = [
      {
        crypto: 'BTC',
        currency: 'USD',
        amount: 0.1,
        value: 11588.32892
      },
      {
        crypto: 'ETH',
        currency: 'USD',
        amount: 2,
        value: 1032.23421
      },
      {
        crypto: 'LTC',
        currency: 'USD',
        amount: 4,
        value: 153.2343242
      }
    ]

  }

  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-title>
              cryptoPWA
            </ion-title>
          </ion-toolbar>
        </ion-header>

        <ion-content>

          {this.renderWelcomeMessage()}

          <ion-list no-lines>

          </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 are now calling this.renderWelcomeMessage() from within our render function. The renderWelcomeMessage function checks the length of the this.holdings array, and if it is empty it will include the message, otherwise it will just be empty. We are using the ternary operator here, which is just shorthand for if/else:

conditionToCheck ? doThisIfTrue : doThisIfFalse

We won’t see the effects of this whilst there is holdings in the holdings array, but if you temporarily comment them out you will be able to see the message displayed.

NOTE: The message we added above uses a <stencil-route-link>. This won’t work yet as we haven’t set up that route – we will be getting to this in the next tutorial.

Looping Over Data

The final concept we are going to cover in this tutorial is looping over data in the template like we would with *ngFor in Angular. As with everything else we are doing, we are not relying on any framework specific syntax. So, to loop over our data we will just be using standard JavaScript.

To loop over data in the template, we can just use a map function on the array:

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

    <div>
        {holding.crypto}
    </div>

)}

By using a map, we are looping through each item in the array, and then returning the template we want for each of those items. Now let’s implement that in our application.

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

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

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

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

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

  componentWillLoad(){

    // just set up some dummy data for now
    this.holdings = [
      {
        crypto: 'BTC',
        currency: 'USD',
        amount: 0.1,
        value: 11588.32892
      },
      {
        crypto: 'ETH',
        currency: 'USD',
        amount: 2,
        value: 1032.23421
      },
      {
        crypto: 'LTC',
        currency: 'USD',
        amount: 4,
        value: 153.2343242
      }
    ]

  }

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

}

Apart from the syntax difference to Ionic/Angular, we mostly just use the same approach.

Adding Styles

There isn’t really anything special about adding styles to the application, we just add them to the .scss file like we would in an Ionic/Angular application. To make our application look a little nicer, let’s add some styles.

Modify src/pages/app-home/app-home.scss to reflect the following:

app-home {

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

    ion-item-sliding {

        margin-top: 10px;

        .item {

            background-color: #f6f6f6;
            text-transform: uppercase;
            padding: 10px;

            .input-wrapper {
                display: block;
            }

            .amount {
                font-size: 0.7em;
            }

        }

        ion-item-options {

            .delete-button {
                height: 100%;
                margin: 0;
            }

        }

    }

    .value {
        margin-top: 10px;
        font-size: 1.2em;
        color: #32db64;
    }

}

After making that change, you should have something that looks like this:

PWA built with Stencil

Summary

With the use of Ionic web components, our pages mostly look the same as they ever did using Ionic/Angular. The main thing that will take a bit more getting used to is the switch to the JSX style templates. Although Angular introduces its own syntax like *ngIf and *ngFor for conditionals and looping, I do find Angular’s approach more intuitive. This code for example:

        <rest-of-the-template>

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

                <div>
                    {holding.value}
                </div>

            )}

        </rest-of-the-template>

takes a bit of breaking down in the ole noggin before it makes sense, whereas Angular’s approach:

<rest-of-template>
    <div *ngFor="let holding of holdings">
        {{holding.value}}
    </div>
</rest-of-template>

is a little more friendly (at least in my opinion). This is a good example of the bells and whistles a framework provides. A lot of the time, people will find these convenience features are going to be worth the extra weight of a framework like Angular (and obviously Angular has more to offer than just its structural directives). With Stencil, we are relying on just the default browser tech, which is super cool, but it always ends up depending on your own circumstances and goals to determine which approach suits better.

What to watch next...