Tutorial hero
Lesson icon

Building a Notepad Application from Scratch with Ionic (StencilJS)

Originally published July 23, 2019 Time 43 mins

A difficult part of the beginner’s journey to learning Ionic is the step between understanding some of the basics and actually building a fully working application for the first time. Concepts explained in isolation can make sense, but it may be difficult to understand when to use them in the context of building an application. Even after spending some time learning about Ionic, you might find yourself starting a new project with no idea where to begin.

The aim of this tutorial is to provide a complete walkthrough of building a simple (but not too simple) Ionic application from start to finish using StencilJS. I have attempted to strike a balance between optimised/best-practice code, and something that is just straight-forward and easy enough for beginners to understand. Sometimes the “best” way to do things can look a lot more complex and intimidating, and don’t serve much of a purpose for beginners until they have the basics covered. You can always introduce more advanced concepts to your code as you continue to learn.

I will be making it a point to take longer to explain smaller concepts in this tutorial, more so than in some of my other tutorials, since it is targeted at people right at the beginning of their Ionic journey. However, I will not be able to cover everything in the level of depth required if you do not already have somewhat of an understanding. You may still find yourself stuck on certain concepts. I will mostly be including just enough to introduce you to the concept in this tutorial, and I will link out to further resources to explain those concepts in more depth where possible.

What will we be building?

The example we will be building needs to be simple enough such that it won’t take too much effort to build, but it also needs to be complex enough to be a realistic representation of a real application.

I decided on building a notepad application for this example, as it will allow us to cover many of the core concepts such as:

  • Navigation/Routing
  • Templates
  • Displaying data
  • Event binding
  • Types and Interfaces
  • Services
  • Data Input
  • Data Storage
  • Styling

As well as covering important concepts, it is also a reasonably simple and fun application to build. In the end, we will have something that looks like this:

Screenshot of a notepad application in Ionic

We will have an application that allows us to:

  • Create, delete, and edit notes
  • Save and load those notes from storage
  • View a list of notes
  • View note details

Before We Get Started

Last updated for Ionic 4.6.2

This tutorial will assume that you have read at least some introductory content about Ionic, have a general understanding of what it is, and that you have everything that you need to build Ionic applications set up on your machine. You will also need to have a basic understanding of HTML, CSS, and JavaScript.

If you do not already have everything set up, or you don’t have a basic understanding of the structure of an Ionic/StencilJS project, take a look at the additional resource below for a video walkthrough.

Additional resources:

1. Generate a New Ionic Project

Time to get started! To begin, we will need to generate a new Ionic/StencilJS application. We can do that with the following command:

npm init stencil

At this point, you will be prompted to pick a “starter” from these options:

? Pick a starter › - Use arrow-keys. Return to submit.

❯  ionic-pwa     Everything you need to build fast, production ready PWAs
   app           Minimal starter for building a Stencil app or website
   component     Collection of web components that can be used anywhere

We will want to choose the ionic-pwa option, which will create a StencilJS application for us with Ionic already installed. Just select that option with your arrow keys, and then hit Enter.

You will also need to supply a Project name, you can use whatever you like for this, but I have called my project ionic-stencil-notepad. After this step, just hit Y when asked to confirm.

You can now make this new project your working directory by running the following command:

cd ionic-stencil-notepad

and you can run the application in the browser with this command:

npm start

2. Create the Required Pages/Services

This application will have two pages:

  • Home (a list of all notes)
  • Detail (the details of a particular note)

and we will also be making use of the following services/providers:

  • Notes
  • Storage

The Notes service will be responsible for handling most of the logic around creating, updating, and deleting notes. The Storage service will be responsible for helping us store and retrieve data from local storage. We are going to create these now so that we can just focus on the implementation later.

The application is generated with an app-home component (we use “components” as our “pages”) by default, so we can keep that and make use of it, but we will get rid of the app-profile component that is also automatically generated.

Delete the following folder:

  • src/components/app-profile

Create the following files in your project:

  • src/components/app-detail/app-detail.tsx
  • src/components/app-detail/app-detail.css
  • src/services/notes.ts
  • src/services/storage.ts

We have created an additional app-detail component for our Detail page - this component includes both a .tsx file that will contain the template and logic, and a .css file for styling. We also create a notes.ts file that will define our Notes service and a storage.ts file that will define our Storage service. Notice that these files have an extension of .ts which is just a regular TypeScript file, as opposed to the .tsx extension the component has which is a TypeScript + JSX file. If you are not already familiar with JSX/TSX then I would recommend that you read the following resource before continuing.

Additional resources:

3. Setting up Navigation/Routing

Now we move on to our first real bit of work - setting up the routes for the application. The “routes” in our application determine which page/component to show when a particular URL path is active. We will already have most of the work done for us by default, we will just need to add/change a couple of things.

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

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

@Component({
  tag: 'app-root',
  styleUrl: 'app-root.css',
})
export class AppRoot {
  render() {
    return (
      <ion-app>
        <ion-router useHash={false}>
          <ion-route url="/" component="app-home" />
          <ion-route url="/notes" component="app-home" />
          <ion-route url="/notes/:id" component="app-detail" />
        </ion-router>
        <ion-nav />
      </ion-app>
    );
  }
}

Routes in our Ionic/StencilJS application are defined by using the <ion-route> component. If you require more of an introduction to navigation in an Ionic/StencilJS application, check out the additional resource below:

Additional resources:

We have kept the default route for app-home but we have also added an additional /notes route that will link to the same component. This is purely cosmetic and is not required. By doing this, I think that the URL structure will make a little more sense. For example, to view all notes we would go to /notes and to view a specific note we would go to /notes/4.

We’ve also added a route for the app-detail component that looks like this:

/notes/:id

By adding :id to the path, which is prefixed by a colon : we are creating a route that will accept parameters which we will be able to grab later (i.e. URL parameters allow us to pass dynamic values through the URL). That means that if a user goes to the following URL:

http://localhost:8100/notes

They will be taken to the Home page. However, if they go to:

http://localhost:8100/notes/12

They will be taken to the Detail page, and from that page, we will be able to grab the :id value of 12. We will make use of that id to display the appropriate note to the user later.

4. Starting the Notes/Home Page

The Home page, which will display a list of all of the notes the user has added, will be the default screen that user sees. We are going to start implementing the template for this page now. We don’t have everything in place that we need to complete it, so we will just be focusing on getting the basic template set up.

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

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

@Component({
  tag: 'app-home',
  styleUrl: 'app-home.css',
})
export class AppHome {
  render() {
    return [
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>Notes</ion-title>
          <ion-buttons slot="end">
            <ion-button>
              <ion-icon slot="icon-only" name="clipboard" />
            </ion-button>
          </ion-buttons>
        </ion-toolbar>
      </ion-header>,

      <ion-content>
        <ion-list>
          <ion-item button detail>
            <ion-label>Title goes here</ion-label>
          </ion-item>
        </ion-list>
      </ion-content>,
    ];
  }
}

This template is entirely made up of Ionic’s web components. We have the <ion-header> which contains the title for our page, and we have <ion-buttons> set up inside of the <ion-toolbar> which will allow us to place a set of buttons either to the left or the right of the toolbar. In this case, we just have a single button in the end slot which we are using as the button to trigger adding a new note. By giving our icon a slot of icon-only it will style the icon so that it is larger (since it doesn’t have text accompanying it).

Then we have our <ion-content> section which provides the main scrolling content section of the page. This contains an <ion-list> with a single <ion-item>. Later, we will modify this list to create multiple items - one for each of the notes that are currently stored in the application.

There will be more information available about all of this, including “slots”, in the additional resources at the end of this section.

Although we will not be making any major changes to the class/logic for our Home page just yet, let’s make a few minor changes so that we are ready for later functionality.

There are two key bits of functionality that we will want to interact with from our pages in this application. The first is Ionic’s ion-alert-controller which will allow us to display alert prompts and request user input (e.g. we will prompt the user for the title of the note when they want to create a new note). The second is Ionic’s ion-router, so that we can programatically control navigation (among other things which we will touch on later).

To use this functionality, we need to get a reference to the Ionic web components that provide that functionality - this is done quite simply enough by using document.querySelector to grab a reference to the actual web component in the document. If you are not familiar with this concept, I would recommend first watching the additional resource below:

Additional resources:

We already have an <ion-router> in our application (since that is used to contain our routes), so we can just grab a reference to that whenever we need it. However, in order to create alerts we will need to add the <ion-alert-controller> to our application. We will add this to the root components template.

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

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

@Component({
  tag: 'app-root',
  styleUrl: 'app-root.css',
})
export class AppRoot {
  render() {
    return (
      <ion-app>
        <ion-router useHash={false}>
          <ion-route url="/" component="app-home" />
          <ion-route url="/notes" component="app-home" />
          <ion-route url="/notes/:id" component="app-detail" />
        </ion-router>
        <ion-alert-controller />
        <ion-nav />
      </ion-app>
    );
  }
}

Notice that we have added <ion-alert-controller> in the template above. Now we will just need to grab a reference to that in our home page (and we are going to add a couple more things here as well).

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

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

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

  addNote() {
    const alertCtrl = document.querySelector('ion-alert-controller');
    console.log(alertCtrl);
  }

  render() {
    return [
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>Notes</ion-title>
          <ion-buttons slot="end">
            <ion-button>
              <ion-icon slot="icon-only" name="clipboard" />
            </ion-button>
          </ion-buttons>
        </ion-toolbar>
      </ion-header>,

      <ion-content>
        <ion-list>
          <ion-item button detail>
            <ion-label>Title goes here</ion-label>
          </ion-item>
        </ion-list>
      </ion-content>,
    ];
  }
}

You can see that we have created an addNote method that we will use for creating new notes. We haven’t fully implemented this yet, but we have created the reference to the ion-alert-controller that we need in order to launch the alert prompt that will ask the user for the title of their note.

We have also added a componentDidLoad lifecycle hook - this functions just like a regular method, except that it will be triggered automatically as soon as the home component has loaded. We will make use of this later.

It’s going to be hard for us to go much further than this without starting to work on our Notes service, as this is what is going to allow us to add, update, and delete notes. Without it, we won’t have anything to display!

5. Creating an Interface

Before we implement our Notes service, we are going to define exactly “what” a note is by creating our own custom type with an interface.

StencilJS uses TypeScript, which is basically JavaScript with “types”. A type gives us a way to enforce that data is what we expect it to be, and you will often see variables, methods, and parameters followed by a colon : and then a type. For example:

let myName: string = 'Josh';

Since we have added : string after the variable we defined, it will enforce that myName can only be a string. If we tried to assign a number to myName it wouldn’t allow us to do it. I don’t want to dive too deep into types and interfaces here, so I will link to another tutorial in the additional resources section below.

Create a folder and file at src/interfaces/note.ts and add the following:

export interface Note {
  id: string;
  title: string;
  content: string;
}

This will allow us to import a Note type elsewhere in our application, which will allow us to force our notes to follow the format defined above. Each note must have an id that is a string, a title that is a string, and content that is a string.

Additional resources:

6. Implement the Notices Service

The “page” components in our application are responsible for displaying views/templates on the screen to the user. Although they are able to implement logic of their own, the majority of the complex logic performed in our applications should be done in “services”.

A service does not display anything to the user, it is just a “helper” that is used by components/pages in our application to perform logic/data operations. Our pages can then call the methods of this service to do work for it. This way, we can keep the code for our pages light, and we can also share the data and methods available through services with multiple pages in our application (whereas, if we define methods in our pages they are only accessible to that one page). Services are the primary way that we are able to share data between different pages.

Our Notes service will implement various methods which will allow it to:

  • Create a note and add it to an array in the data service
  • Delete notes from that array
  • Find and return a specific note by its id
  • Save the array of notes to storage
  • Load the array of notes from storage

Since our Notes service will rely on adding data to, and retrieving data from, the browsers local storage (which will allow us to persist notes across application reloads), we should tackle creating the Storage service first.

Modify src/services/storage.ts to reflect the following:

const storage = window.localStorage;

export function set(key: string, value: any): Promise<void> {
  return new Promise((resolve, reject) => {
    try {
      storage && storage.setItem(key, JSON.stringify(value));
      resolve();
    } catch (err) {
      reject(`Couldnt store object ${err}`);
    }
  });
}

export function remove(key: string): Promise<void> {
  return new Promise((resolve, reject) => {
    try {
      storage && storage.removeItem(key);
      resolve();
    } catch (err) {
      reject(`Couldnt remove object ${err}`);
    }
  });
}

export function get(key: string): Promise<any> {
  return new Promise((resolve, reject) => {
    try {
      if (storage) {
        const item = storage.getItem(key);
        resolve(JSON.parse(item));
      }
      resolve(undefined);
    } catch (err) {
      reject(`Couldnt get object: ${err}`);
    }
  });
}

The main purpose of this service is to provide three methods that we can easily use to interact with local storage: get, set, and remove. This service allows us to contain all of the “ugly” code in one place, and then throughout the rest of the application we can just make simple calls to get, set, and remove to store the data that we want.

For more information about how browser based local storage works, and a more advanced solution for dealing with storage, check out the additional resource below. The solution detailed in the tutorial below will make use of the best storage mechanism available depending on the platform the application is running on (e.g. on iOS and Android it will use native storage, instead of the browsers local storage). This is generally a better solution than the above, but it does depend on using Capacitor in your project.

Additional resources:

Now let’s implement the code for our Notes service, and then talk through it. I’ve added comments to various parts of the code itself, but we will also talk through it below.

import { set, get } from "./storage";
import { Note } from "../interfaces/note";

class NotesServiceController {
  public notes: Note[];

  async load(): Promise<Note[]> {
    if (this.notes) {
      return this.notes;
    } else {
      this.notes = (await get("notes")) || [];
      return this.notes;
    }
  }

  async save(): Promise<void> {
    return await set("notes", this.notes);
  }

  getNote(id): Note {
    return this.notes.find(note => note.id === id);
  }

  createNote(title): void {
    // Create a unique id that is one larger than the current largest id
    let id = Math.max(...this.notes.map(note => parseInt(note.id)), 0) + 1;

    this.notes.push({
      id: id.toString(),
      title: title,
      content: ""
    });

    this.save();
  }

  updateNote(note, content): void {
    // Get the index in the array of the note that was passed in
    let index = this.notes.indexOf(note);

    this.notes[index].content = content;
    this.save();
  }

  deleteNote(note): void {
    // Get the index in the array of the note that was passed in
    let index = this.notes.indexOf(note);

    // Delete that element of the array and resave the data
    if (index > -1) {
      this.notes.splice(index, 1);
      this.save();
    }
  }
}

export const NotesService = new NotesServiceController();

First of all, if you are not familiar with the general concept of a “service” in StencilJS, I would recommend reading the following additional resource first.

Additional resources:

At the top of the file, we import our Storage methods that we want to make use of, and the interface that we created to represent a Note. Inside of our service, we have set up a notes class member which will be an array of our notes (the Note[] type means it will be an array of our Note type we created).

Variables declared above the methods in our service (like in any class) will be accessible throughout the entire class using this.notes.

Our load function is responsible for loading data in from storage (if it exists) and then setting it up on the this.notes array. If the data has already been loaded we return it immediately, otherwise we load the data from storage first. If there is no data in storage (e.g. the result from the get call is null) then we instead return an empty array (e.g. []) instead of a null value. This method (and others) are marked as async which means that they run outside of the normal flow of the application and can “wait” for operations to complete whilst the rest of the application continues executing. In this case, if we need to load data in from storage then we need to “wait” for that operation to complete.

It is important to understand the difference between synchronous and asynchronous code behaviour, as well as how async/await works. If you are not already familiar with this, then you can check out the following resources.

Additional resources:

The save function in our service simply sets our notes array on the notes key in storage so that it can be retrieved later - we will call this whenever there is a change in data, so that when we reload the application the changes are still there.

The getNote function will accept an id, it will then search through the notes array and return the note that matches the id passed in. We will make use of this in our detail page later.

The createNote function handles creating a new note and pushing it into the notes array. We do have an odd bit of code here:

let id = Math.max(...this.notes.map((note) => parseInt(note.id)), 0) + 1;

We need each of our notes to have a unique id so that we can grab them appropriately through the URL. To keep things simple, we are using a simple numeric id (an id could be anything, as long as it is unique). To ensure that we always get a unique id, we use this code to find the note with the largest id that is currently in the array, and then we just add 1 to that.

The updateNote method will find a particular note and update its content, and the deleteNote method will find a particular note and remove it.

7. Finishing the Notes Page

With our notes service in place, we can now finish off our Home page. We will need to make some modifications to both the class and the template.

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

import { Component, State, h } from '@stencil/core';
import { Note } from '../../interfaces/note';
import { NotesService } from '../../services/notes';

@Component({
  tag: 'app-home',
  styleUrl: 'app-home.css',
})
export class AppHome {
  @State() notes: Note[] = [];

  async componentDidLoad() {
    this.notes = await NotesService.load();
  }

  async addNote() {
    const alertCtrl = document.querySelector('ion-alert-controller');

    let alert = await alertCtrl.create({
      header: 'New Note',
      message: 'What should the title of this note be?',
      inputs: [
        {
          type: 'text',
          name: 'title',
        },
      ],
      buttons: [
        {
          text: 'Cancel',
        },
        {
          text: 'Save',
          handler: async (data) => {
            NotesService.createNote(data.title);
            this.notes = [...(await NotesService.load())];
          },
        },
      ],
    });

    alert.present();
  }

  render() {
    return [
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>Notes</ion-title>
          <ion-buttons slot="end">
            <ion-button>
              <ion-icon slot="icon-only" name="clipboard" />
            </ion-button>
          </ion-buttons>
        </ion-toolbar>
      </ion-header>,

      <ion-content>
        <ion-list>
          <ion-item button detail>
            <ion-label>Title goes here</ion-label>
          </ion-item>
        </ion-list>
      </ion-content>,
    ];
  }
}

We have now added a call to the load method of the Notes service, which will handle loading in the data from storage as soon as the application has started. This will also set up the data on the notes class member in the home page. Notice that we have also decorated our notes class member with the @State() decorator - since we want the template to update whenever notes changes, we need to add the @State() decorator to it (otherwise, the template will not re-render and it will continue to display old data). For more information on this, check out the additional resource below.

Additional resources:

The addNote() method will now also allow the user to add a new note. We will create an event binding in the template later to tie a button click to this method, which will launch an Alert prompt on screen. This prompt will allow the user to enter a title for their new note, and when they click Save the new note will be added. Since creating the alert prompt is “asynchronous” we need to mark the addNote method as async in order to be able to await the creation of the alert prompt. In the “handler” for this prompt, we trigger adding the new note using our service, and we also reload the data in our home page so that it includes the newly added note by reassigning this.notes. The reason we use this weird syntax:

this.notes = [...(await NotesService.load())];

instead of just this:

this.notes = await NotesService.load();

Is because in order for StencilJS to detect a change and display it in the template, the variable must be reassigned completely (not just modified). In the second example the data would be updated, but it would not display in the template. The first example creates a new array like this:

this.notes = [
  /* values in here */
];

and inside of that new array, the “spread” operator (i.e. ...) is used to pull all of the values out of the array returned from the load call, and add them to this new array. For a real world analogy, consider having a box full of books. Instead of just taking a book out of the box full of books to get the result we want, we are getting a new empty box and moving all of the books over to this new box (except for the books we no longer want). This is a round-a-bout way of doing the exact same thing, but the difference is that by moving all of our books to the new box StencilJS will be able to detect and display the change.

This isn’t really intuitive, but if you ever run into a situation in StencilJS where you are updating your data but not seeing the change in your template, it’s probably because you either:

  1. Didn’t use the @State decorator, or
  2. You are modifying an array/object instead of creating a new array/object

Now we just need to finish off the template.

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

import { Component, State, h } from '@stencil/core';
import { Note } from '../../interfaces/note';
import { NotesService } from '../../services/notes';

@Component({
  tag: 'app-home',
  styleUrl: 'app-home.css',
})
export class AppHome {
  @State() notes: Note[] = [];

  async componentDidLoad() {
    this.notes = await NotesService.load();
  }

  async addNote() {
    const alertCtrl = document.querySelector('ion-alert-controller');

    let alert = await alertCtrl.create({
      header: 'New Note',
      message: 'What should the title of this note be?',
      inputs: [
        {
          type: 'text',
          name: 'title',
        },
      ],
      buttons: [
        {
          text: 'Cancel',
        },
        {
          text: 'Save',
          handler: async (data) => {
            NotesService.createNote(data.title);
            this.notes = [...(await NotesService.load())];
          },
        },
      ],
    });

    alert.present();
  }

  render() {
    return [
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>Notes</ion-title>
          <ion-buttons slot="end">
            <ion-button onClick={() => this.addNote()}>
              <ion-icon slot="icon-only" name="clipboard" />
            </ion-button>
          </ion-buttons>
        </ion-toolbar>
      </ion-header>,

      <ion-content>
        <ion-list>
          {this.notes.map((note) => (
            <ion-item
              button
              detail
              href={`/notes/${note.id}`}
              routerDirection="forward"
            >
              <ion-label>{note.title}</ion-label>
            </ion-item>
          ))}
        </ion-list>
      </ion-content>,
    ];
  }
}

We have modified our button in the header section to include an onClick event binding that is linked to the addNote() method. This will trigger the addNote() method we just created whenever the user clicks on the button.

We have also modified our notes list:

<ion-list>
  {this.notes.map((note) => (
    <ion-item
      button
      detail
      href={`/notes/${note.id}`}
      routerDirection="forward"
    >
      <ion-label>{note.title}</ion-label>
    </ion-item>
  ))}
</ion-list>

Rather than just having a single item, we are now using a map which will loop over each note in our notes array. Since we want to view the details of an individual note by clicking on it, we set up the following href value:

href={`/notes/${note.id}`}

By using curly braces here, we are able to render out the value of whatever note.id is in our string. In this case, we want to first evaluate the expression /notes/${note.id} to something like '/notes/12' before assigning it to the href, we don’t want the value to literally be /notes/${note.id}. We also supply a routerDirection of forward so that Ionic knows the “direction” of the page transition, which will make sure that it animates that transition correctly (there are different page transition animations for a forward and backward transition).

Finally, we use an “interpolation” to render out a specific notes title value inside of the <ion-label>. An interpolation, which is an expression surrounded by curly braces, is just a way to evaluate an expression before rendering it out on the screen. Therefore, this will display the title of whatever note is currently being looped over in our map.

These concepts are expanded upon in the tutorial on JSX, so make sure to check that out if you are feeling a little lost at this point.

Additional resources:

8. Implement the Notes Detail Page

We just have one more page to implement to finish off the functionality in the application! We want our Detail page to allow the user to:

  • View the details of a note
  • Edit the content of a note
  • Delete a note

Let’s start off with just the logic, and then we will implement the template.

Modify src/components/app-detail/app-detail.tsx

import { Component, State, Prop, h } from "@stencil/core";
import { Note } from "../../interfaces/note";
import { NotesService } from "../../services/notes";

@Component({
  tag: "app-detail",
  styleUrl: "app-detail.css"
})
export class AppDetail {
  public navCtrl = document.querySelector("ion-router");

  @Prop() id: string;

  @State() note: Note = {
    id: null,
    title: "",
    content: ""
  };

  async componentDidLoad() {
    await NotesService.load();
    this.note = await NotesService.getNote(this.id);
  }

  noteChanged(ev) {
    NotesService.updateNote(this.note, ev.target.value);
    NotesService.save();
  }

  deleteNote() {
    setTimeout(() => {
      NotesService.deleteNote(this.note);
    }, 300);
    this.navCtrl.back();
  }

  render() {
    return [
      <ion-content />
    ];
  }
}

In this page, we use the @Prop decorator. If we give this prop the same name as the parameter we set up in our <ion-route> to pass in the id this will allow us to get access to the value that was passed in through the URL. We want to do that because we need to access the id of the note that is supplied through the route, e.g:

http://localhost:8100/notes/12

In our componentDidLoad method, we then use that id to grab the specific note from the notes service. However, we first have to check if the data has been loaded yet since it is possible to load this page directly through the URL (rather than going through the home page first). To make sure that the data has been loaded, we just make a call to the load method from our Notes service. The note is then assigned to the this.note class member.

Since the note is not immediately available to the template, we intialise an empty note with blank values so that our template won’t complain about data that does not exist. Once the note has been successfully fetched, the data in the template will automatically update since we are using the @State decorator.

We have a noteChanged method set up, which will be called every time the data for the note changes. This will ensure that any changes are immediately saved.

We also have a deleteNote function that will handle deleting the current note. Once the note has been deleted, we automatically navigate back to the Home page by using the back method of the ion-router. We actually wait for 300ms using a setTimeout before we delete the note, since we want the note to be deleted after we have navigated back to the home page.

Modify the template in src/components/app-detail/app-detail.tsx to reflect the following:

(
  <ion-header>
    <ion-toolbar color="primary">
      <ion-buttons slot="start">
        <ion-back-button defaultHref="/notes" />
      </ion-buttons>
      <ion-title>{this.note.title}</ion-title>
      <ion-buttons slot="end">
        <ion-button onClick={() => this.deleteNote()}>
          <ion-icon slot="icon-only" name="trash" />
        </ion-button>
      </ion-buttons>
    </ion-toolbar>
  </ion-header>
),
  (
    <ion-content class="ion-padding">
      <ion-textarea
        onInput={(ev) => this.noteChanged(ev)}
        value={this.note.content}
        placeholder="...something on your mind?"
      />
    </ion-content>
  );

This template is not too dissimilar to our home page. Since we have our note set up as a class member variable, we can access it inside of <ion-title> to display the title of the current note in this position.

We have our delete button set up in the header which is bound to the deleteNote method we defined, but we also have an additional button this time called <ion-back-button>. This is an Ionic component that simply displays a button that will handle allowing the user to navigate backward. Since it is possible to load the Detail page directly through the URL, and we still want the user to be able to navigate “back” to the Home page in this instance, we supply the back button with a defaultHref of /notes which will cause it to navigate to the /notes route by default if no navigation history is available.

Inside of our content area, we just have a single <ion-textarea> component for the user to write in. Whenever this value changes, we trigger the noteChanged method and pass in the new value. This will then save that new value.

This is a rather simplistic approach to forms. If you would like a more advanced look at how to manage forms in StencilJS, take a look at this preview chapter from my book Creating Ionic Applications with StencilJS:

Additional resources:

We will actually need to make one more change to our home page to finish off the functionality for the application. Currently, the home page loads the data when the component first loads. However, since we can now delete notes, that means the data on the home page might need to change as a result of what happens on our detail page.

To account for this, we are going to set up a listener that will detect every time that the home page is loaded, and we will be able to run some code to refresh the data.

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

import { Component, State, h } from "@stencil/core";
import { Note } from "../../interfaces/note";
import { NotesService } from "../../services/notes";

@Component({
  tag: "app-home",
  styleUrl: "app-home.css"
})
export class AppHome {
  @State() notes: Note[] = [];
  public navCtrl = document.querySelector("ion-router");

  async componentDidLoad() {
    this.navCtrl.addEventListener("ionRouteDidChange", async () => {
      this.notes = [...(await NotesService.load())];
    });
  }

  async addNote() {
    const alertCtrl = document.querySelector("ion-alert-controller");

    let alert = await alertCtrl.create({
      header: "New Note",
      message: "What should the title of this note be?",
      inputs: [
        {
          type: "text",
          name: "title"
        }
      ],
      buttons: [
        {
          text: "Cancel"
        },
        {
          text: "Save",
          handler: async data => {
            NotesService.createNote(data.title);
            this.notes = [...(await NotesService.load())];
          }
        }
      ]
    });

    alert.present();
  }

  render() {
    return [
      <ion-header>
        <ion-toolbar color="primary">
          <ion-title>Notes</ion-title>
          <ion-buttons slot="end">
            <ion-button onClick={() => this.addNote()}>
              <ion-icon slot="icon-only" name="clipboard" />
            </ion-button>
          </ion-buttons>
        </ion-toolbar>
      </ion-header>,

      <ion-content>
        <ion-list>
          {this.notes.map(note => (
            <ion-item button detail href={`/notes/${note.id}`} routerDirection="forward">
              <ion-label>{note.title}</ion-label>
            </ion-item>
          ))}
        </ion-list>
      </ion-content>
    ];
  }
}

We now have a reference to the ion-router and we set up a listener for the ionRouteDidChange event which will be triggered every time this page is activated.

9. Styling the Application

The functionality of our application is complete now. But we are going to take it one step further and make it look a little nicer. You may have noticed in the templates that we reference color="primary" in the toolbars. Ionic has a default set of colours defined that we can use, including:

  • primary
  • secondary
  • tertiary
  • light
  • danger
  • dark

We can use these default colours, or we can define our own in the src/global/app.scss file. You will find a bunch of CSS variables in this file that can be modified (if you aren’t familiar with CSS4 variables, I would recommend checking out the additional resources section near the end of this article). You can just modify these manually, but it is a better idea to use Ionic’s Color Generator to generate all of the necessary values for you, you just pick the colours that you want:

You can create your own colour scheme, or use the one I have created below.

Add the following to the bottom of src/global/app.scss:

// Ionic Variables and Theming. For more info, please see:
// http://ionicframework.com/docs/theming/

/** Ionic CSS Variables **/
:root {
  --ion-color-primary: #f10090;
  --ion-color-primary-rgb: 241, 0, 144;
  --ion-color-primary-contrast: #ffffff;
  --ion-color-primary-contrast-rgb: 255, 255, 255;
  --ion-color-primary-shade: #d4007f;
  --ion-color-primary-tint: #f21a9b;

  --ion-color-secondary: #da0184;
  --ion-color-secondary-rgb: 218, 1, 132;
  --ion-color-secondary-contrast: #ffffff;
  --ion-color-secondary-contrast-rgb: 255, 255, 255;
  --ion-color-secondary-shade: #c00174;
  --ion-color-secondary-tint: #de1a90;

  --ion-color-tertiary: #7044ff;
  --ion-color-tertiary-rgb: 112, 68, 255;
  --ion-color-tertiary-contrast: #ffffff;
  --ion-color-tertiary-contrast-rgb: 255, 255, 255;
  --ion-color-tertiary-shade: #633ce0;
  --ion-color-tertiary-tint: #7e57ff;

  --ion-color-success: #10dc60;
  --ion-color-success-rgb: 16, 220, 96;
  --ion-color-success-contrast: #ffffff;
  --ion-color-success-contrast-rgb: 255, 255, 255;
  --ion-color-success-shade: #0ec254;
  --ion-color-success-tint: #28e070;

  --ion-color-warning: #ffce00;
  --ion-color-warning-rgb: 255, 206, 0;
  --ion-color-warning-contrast: #ffffff;
  --ion-color-warning-contrast-rgb: 255, 255, 255;
  --ion-color-warning-shade: #e0b500;
  --ion-color-warning-tint: #ffd31a;

  --ion-color-danger: #f04141;
  --ion-color-danger-rgb: 245, 61, 61;
  --ion-color-danger-contrast: #ffffff;
  --ion-color-danger-contrast-rgb: 255, 255, 255;
  --ion-color-danger-shade: #d33939;
  --ion-color-danger-tint: #f25454;

  --ion-color-dark: #222428;
  --ion-color-dark-rgb: 34, 34, 34;
  --ion-color-dark-contrast: #ffffff;
  --ion-color-dark-contrast-rgb: 255, 255, 255;
  --ion-color-dark-shade: #1e2023;
  --ion-color-dark-tint: #383a3e;

  --ion-color-medium: #989aa2;
  --ion-color-medium-rgb: 152, 154, 162;
  --ion-color-medium-contrast: #ffffff;
  --ion-color-medium-contrast-rgb: 255, 255, 255;
  --ion-color-medium-shade: #86888f;
  --ion-color-medium-tint: #a2a4ab;

  --ion-color-light: #f4f5f8;
  --ion-color-light-rgb: 244, 244, 244;
  --ion-color-light-contrast: #000000;
  --ion-color-light-contrast-rgb: 0, 0, 0;
  --ion-color-light-shade: #d7d8da;
  --ion-color-light-tint: #f5f6f9;
}

By supplying these variables to the :root selector, these variable changes will take affect application wide (although they can still be overwritten by more specific selectors).

This is a good way to add some basic styling to the application, but we might want to get a little more specific. Let’s say we want to change the background colour of all of our <ion-content> sections. We can do that too.

Add the following style to the top of src/global/app.css:

/* Document Level Styles */

ion-content {
  --ion-background-color: var(--ion-color-secondary);
}

Unlike the :root selector which defines variables everywhere, this will only overwrite the --ion-background-color variable inside of any <ion-content> tags. We set the background colour to whatever the --ion-color-secondary value is, but we could also just use a normal colour value here instead of a variable if we wanted to.

We can also add styles just to specific pages if we want. Let’s make some changes to the Home page and Detail page.

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

ion-item {
  --ion-background-color: #fff;
  width: 95%;
  margin: 15px auto;
  font-weight: bold;
  border-radius: 20px;
  --border-radius: 20px;
}

This adds a bit of extra styling to our <ion-item>. We want it to have a rounded border so we supply the border-radius property, but notice we are kind of doing this twice. Inside of the <ion-item> there is a ripple effect component that applies a little animation when the item is clicked. We need the border of this effect to be rounded as well as the item itself. Since the ripple effect is inside of a Shadow DOM, we can only change its style by modifying the --border-radius CSS variable. I realise this probably sounds very confusing, so I would highly recommend checking out the additional resources below on Shadow DOM as this is a big part of styling in Ionic.

Modify src/components/app-detail/app-detail.css to reflect the following:

ion-textarea {
  background: #fff !important;
  border-radius: 20px;
  padding: 20px;
  height: 100%;
}

This just adds some simple styling to the textarea, and with that, we’re done!

Additional resources:

Summary

Although this application is reasonably simple, we have covered a lot of different concepts. If you are still just getting started with Ionic, and even if you’ve been at it for a while, there are more than likely a few concepts that we have covered that might not make complete sense yet. After completing the application, I would recommend going back to the additional resources in each section to read further on any concepts you are struggling with.

If you are looking for something to speed up your Ionic/StencilJS learning journey, then take a look at the Creating Ionic Applications with StencilJS book.

Learn to build modern Angular apps with my course