December 14, 2023
  • All
  • Tutorials
  • Angular
  • Tutorials

Building Modern Angular Apps with Ionic and Standalone Components

Simon Grimm

This is a guest post from Simon Grimm, Ionic Developer Expert and educator at the Ionic Academy, an online school with 70+ video courses focused entirely on building awesome mobile apps with Ionic and Capacitor!

With Angular v17, the Angular team introduced many groundbreaking changes to how you can write powerful and performant Angular apps. All these changes are available in Ionic as well, and in this tutorial, we’ll take a look at how you can use them to build modern Angular apps with Ionic.

By the end of this tutorial, you will know how to:

  • Use the new Angular Control Flow
  • Work with Signals
  • Use Deferred Blocks
  • Import Ionic Standalone Components

We will build a simple movie app that shows a list of trending movies and allows us to view the details of each movie, as below.

You can also find the full source code on GitHub. Now roll up your sleeves, and let’s get started!

Setting up the Project with Angular 17

To get started, bring up a terminal, create a new Ionic app, and navigate into the project folder. We will use the blank template and the --type angular flag to create an Angular app – make sure you pick Standalone Components when asked about how you want to build your app!

ionic start modernApp blank --type angular

cd ./modernApp

ionic g service services/movie

ionic g page details

Additionally we generate a service to fetch the movie data and a details page to show the details of a movie. To correctly make HTTP calls, we can now add the provideHttpClient to the src/main.ts as we don’t have a main module anymore:

import { enableProdMode, importProvidersFrom } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { RouteReuseStrategy, provideRouter, withComponentInputBinding } from '@angular/router';
import { IonicRouteStrategy, provideIonicAngular } from '@ionic/angular/standalone';

import { routes } from './app/app.routes';
import { AppComponent } from './app/app.component';
import { environment } from './environments/environment';
import { provideHttpClient } from '@angular/common/http';

if (environment.production) {
  enableProdMode();
}

bootstrapApplication(AppComponent, {
  providers: [
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
    provideIonicAngular(),
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(),
  ],
});

In addition to provideHttpClient, we also added withComponentInputBinding to the provideRouter call to enable the automatic binding of route parameters to components. We’ll come back to this once we build the details page.

Angular ESBuild

To make use of all of the powerful new features of Angular 17, we can now enable ESBuild, which makes building our app much faster.

To do so, we need to change the @angular-devkit/build-angular:browser package to @angular-devkit/build-angular:application, end then rename the main property to browser inside the angular.json:

"architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:application",
          "options": {
            "browser": "src/main.ts",
            "polyfills": ["src/polyfills.ts"],

Additionally, polyfills is now an array, and you need to remove buildOptimizer & vendorChunk from the configurations block. With all of that in place, you are now on the fastest possible Angular build!

Adding the Movie Database API

To follow along with this tutorial, you also need to get an API key for the The Movie Database API.

Simply create an account and request one for free, then open the src/environments/environment.ts file and add your API key:

export const environment = {
  production: false,
  apiKey: 'YOURKEY',
};

If you create a production build later, also update the src/environments/environment.prod.ts file with that key.

Creating a Service

To use Typescript in the best possible way, I like to generate Typescript interfaces based on JSON responses from APIs using this VSC Extension.

In our case, this results in some interfaces we can rename and put into a new src/app/services/interfaces.ts file:

export interface ApiResult {
  page: number;
  results: any[];
  total_pages: number;
  total_results: number;
}

export interface MovieResult {
  adult: boolean;
  backdrop_path: string;
  belongs_to_collection?: any;
  budget: number;
  genres: Genre[];
  homepage: string;
  id: number;
  imdb_id: string;
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string;
  production_companies: Productioncompany[];
  production_countries: Productioncountry[];
  release_date: string;
  revenue: number;
  runtime: number;
  spoken_languages: Spokenlanguage[];
  status: string;
  tagline: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
}

interface Spokenlanguage {
  english_name: string;
  iso_639_1: string;
  name: string;
}

interface Productioncountry {
  iso_3166_1: string;
  name: string;
}

interface Productioncompany {
  id: number;
  logo_path?: string;
  name: string;
  origin_country: string;
}

interface Genre {
  id: number;
  name: string;
}

Now we can create a new service to fetch the movie data from the API. Services haven’t changed much, but they don’t have to: React developers would love to have a singleton service construct to inject anywhere!

Services structure our Angular app, split up view and business logic and make it easy to share data between components. Enough of the praise, let’s implement our service to make two API calls inside the src/app/services/movie.service.ts:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { delay } from 'rxjs/operators';
import { Observable } from 'rxjs/internal/Observable';
import { ApiResult, MovieResult } from './interfaces';
import { environment } from 'src/environments/environment';

const BASE_URL = 'https://api.themoviedb.org/3';
const API_KEY = environment.apiKey;

@Injectable({
  providedIn: 'root',
})
export class MovieService {
  private http = inject(HttpClient);

  constructor() {}

  getTopRatedMovies(page = 1): Observable<ApiResult> {
    return this.http
      .get<ApiResult>(`${BASE_URL}/movie/popular?page=${page}&api_key=${API_KEY}`)
      .pipe(
        delay(2000) // Simulate slow network
      );
  }

  getMovieDetails(id: string): Observable<MovieResult> {
    return this.http.get<MovieResult>(`${BASE_URL}/movie/${id}?api_key=${API_KEY}`);
  }
}

As we use the latest and greatest, we now also moved the dependency injection to the class level by using inject directly. 

Using the new Angular Control Flow

We can now start building our app by adding a new page to show the list of trending movies, based on the data we get from our MovieService. Additionally, we will now add imports for all Ionic components that we will later use in our view.

Why? Because we can, thanks to Ionic 7.5!

Instead of importing all Ionic components, we can now pick the ones we actually need from the @ionic/angular/standalone package and include them in our Angular standalone components. While this does add a few lines to our imports, it also makes it easier to see which components we actually use in our view. Additionally, this makes loading pages faster and reduces the final bundle size of our app.

We now need one function to load the trending movies, and another one to load more movies when we scroll to the bottom of the list which triggers the infinite scroll event.

Go ahead by changing the src/app/home/home.page.ts to:

import { Component, OnInit, inject } from '@angular/core';
import {
  IonHeader,
  IonToolbar,
  IonTitle,
  IonContent,
  InfiniteScrollCustomEvent,
  IonBadge,
  IonLabel,
  IonAvatar,
  IonItem,
  IonList,
  IonLoading,
  IonInfiniteScroll,
  IonInfiniteScrollContent,
  IonSkeletonText,
  IonAlert,
} from '@ionic/angular/standalone';
import { MovieService } from '../services/movie.service';
import { DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router';
import { catchError, finalize } from 'rxjs';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
  standalone: true,
  imports: [
    IonHeader,
    IonToolbar,
    IonTitle,
    IonContent,
    IonLabel,
    IonBadge,
    IonAvatar,
    IonItem,
    IonList,
    IonLoading,
    IonInfiniteScroll,
    IonInfiniteScrollContent,
    IonSkeletonText,
    IonAlert,
    DatePipe,
    RouterModule,
  ],
})
export class HomePage implements OnInit {
  private movieService = inject(MovieService);

  private currentPage = 1;
  public movies: any[] = [];
  public imageBaseUrl = 'https://image.tmdb.org/t/p';
  public isLoading = true;
  public error = null;
  public dummyArray = new Array(5);

  // Load the first page of movies during component initialization
  ngOnInit() {
    this.loadMovies();
  }

  async loadMovies(event?: InfiniteScrollCustomEvent) {
    this.error = null;

    // Only show loading indicator on initial load
    if (!event) {
      this.isLoading = true;
    }

    // Get the next page of movies from the MovieService
    this.movieService
      .getTopRatedMovies(this.currentPage)
      .pipe(
        finalize(() => {
          this.isLoading = false;
        }),
        catchError((err: any) => {
          this.error = err.error.status_message;
          return [];
        })
      )
      .subscribe({
        next: (res) => {
          // Append the results to our movies array
          this.movies.push(...res.results);

          // Resolve the infinite scroll promise to tell Ionic that we are done
          event?.target.complete();

          // Disable the infinite scroll when we reach the end of the list
          if (event) {
            event.target.disabled = res.total_pages === this.currentPage;
          }
        },
      });
  }

  // This method is called by the infinite scroll event handler
  loadMore(event: InfiniteScrollCustomEvent) {
    this.currentPage++;
    this.loadMovies(event);
  }
}

Nothing too crazy going on here, so let’s move to the view now. The new Angular Control Flow is a powerful feature that allows us to write cleaner and more readable code.

It’s based on the @if and @for directives, which are similar to the *ngIf and *ngFor directives, but with some important differences.

The @if directive is used to conditionally render a block of code, and it can be used just as you would expect – but inside the HTML:

@if (condition) { ... } 

@else if { ... } 

@else { ... }

The second new directive is @for, which is used to iterate over an array and render a block of code for each item in the array:

@for (item of items; track item.id) { ... }

The track keyword is used to tell Angular how to track the items in the array, and it’s required when using @for.

Let’s build a view around our isLoading state and the value from our API call:

  • If we are loading, we show a list of six Ionic skeleton items
  • If we have an error, we show an Ionic inline alert with the error message
  • When we have an array, we show a list of movies
  • When the array is empty and we finished loading, we show a message that no movies were found.

Bring up the src/app/home/home.page.html now and change it to:

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

<ion-content>
  @if (isLoading) {
  <ion-list class="ion-padding-top">
    @for (i of dummyArray; track i) {
    <ion-item lines="none" class="ion-padding-bottom">
      <ion-avatar slot="start">
        <ion-skeleton-text></ion-skeleton-text>
      </ion-avatar>
      <ion-skeleton-text animated style="height: 40px" />
    </ion-item>
    }
  </ion-list>
  } @else if (error) {
  <ion-alert
    header="Error"
    [message]="error"
    isOpen="true"
    [buttons]="['Ok']"
  />
  }

  <ion-list class="ion-padding-top">
    @for (item of movies; track item.id) {
    <ion-item button [routerLink]="['/details', item.id]">
      <ion-avatar slot="start">
        <img [src]="imageBaseUrl + '/w92' + item.poster_path" />
      </ion-avatar>

      <ion-label class="ion-text-wrap">
        <h3>{{ item.title }}</h3>
        <p>{{ item.release_date | date:'y' }}</p>
      </ion-label>

      <ion-badge slot="end"> {{ item.vote_average }} </ion-badge>
    </ion-item>
    } 
    @empty { 
      @if (!isLoading) {
      <ion-item lines="none">
        <ion-label class="ion-text-center">No movies found</ion-label>
      </ion-item>
      } 
    }
  </ion-list>

  <ion-infinite-scroll (ionInfinite)="loadMore($event)">
    <ion-infinite-scroll-content
      loadingSpinner="bubbles"
      loadingText="Loading more data..."
    />
  </ion-infinite-scroll>
</ion-content>

If you added your API key correctly, you should now see a list of trending movies.

Congratulations on using the new Angular Control Flow with Ionic! You are now officially among the cool kids. Clicking on one of the movies should take us to the details page, so let’s work on that now.

Working with Signals

In the beginning, the CLI automatically changed our routing, but we need to include the :id parameter manually now.

Open up the src/app/app.routes.ts file and change it to:

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'home',
    loadComponent: () => import('./home/home.page').then((m) => m.HomePage),
  },
  {
    path: '',
    redirectTo: 'home-defer',
    pathMatch: 'full',
  },
  {
    path: 'details/:id', // <-- Add the :id parameter
    loadComponent: () =>
      import('./details/details.page').then((m) => m.DetailsPage),
  },
  {
    path: 'home-defer',
    loadComponent: () =>
      import('./home-defer/home-defer.page').then((m) => m.HomeDeferPage),
  },
];

Now the buttons in our list work, and we are able to retrieve the id parameter from the URL on the details page. Remember how we added the withComponentInputBinding to the provideRouter call in the src/main.ts? This is where it comes into play!

We can now use the @Input decorator to bind the id parameter to a variable in our details page, and the set function will be called whenever the value changes.

This makes it really easy for us to immediately load the movie details when the id changes!

On top of that we will also use the new signal function to create a Signal that we can use in our view to show the movie details.

Not really required here, but I wanted to show you how to use Signals! The usage is really easy:

  • Create a new Signal with signal<T>(initialValue)
  • Set the value with set(newValue)
  • Get the value by calling the signal()

Open the src/app/details/details.page.ts now and change it to:

import {
  Component,
  Input,
  WritableSignal,
  inject,
  signal,
} from '@angular/core';
import { MovieService } from '../services/movie.service';
import { MovieResult } from '../services/interfaces';
import {
  IonBackButton,
  IonButtons,
  IonCard,
  IonCardContent,
  IonCardHeader,
  IonCardSubtitle,
  IonCardTitle,
  IonContent,
  IonHeader,
  IonIcon,
  IonItem,
  IonLabel,
  IonText,
  IonTitle,
  IonToolbar,
} from '@ionic/angular/standalone';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { addIcons } from 'ionicons';
import { cashOutline, calendarOutline } from 'ionicons/icons';

@Component({
  selector: 'app-details',
  templateUrl: './details.page.html',
  styleUrls: ['./details.page.scss'],
  standalone: true,
  imports: [
    IonHeader,
    IonToolbar,
    IonTitle,
    IonContent,
    IonIcon,
    IonCard,
    IonCardHeader,
    IonCardTitle,
    IonCardSubtitle,
    IonCardContent,
    IonText,
    IonLabel,
    IonButtons,
    IonBackButton,
    IonItem,
    CurrencyPipe,
    DatePipe,
  ],
})
export class DetailsPage {
  private movieService = inject(MovieService);
  public movie: WritableSignal<MovieResult | null> = signal<MovieResult | null>(
    null,
  );
  public imageBaseUrl = 'https://image.tmdb.org/t/p';

  // Load the movie details when the id changes through the URL :id parameter
  @Input()
  set id(movieId: string) {
    // This is just to show Signal usage
    // You could also just assign the value to a variable directly
    this.movieService.getMovieDetails(movieId).subscribe((movie) => {
      this.movie.set(movie);
    });
  }

  constructor() {
    // Load the the required ionicons
    addIcons({
      cashOutline,
      calendarOutline,
    });
  }
}

Almost forgot another change of Ionic standalone components: We can manually add the icons we need to the addIcons function, which will then be included in the final bundle.

Now we can use the movie signal in our view to show the movie details.

We will also use the new control flow again and cast the movie signal to a variable with as movie to make it easier to work with.

Open up the src/app/details/details.page.html file and change it to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/"></ion-back-button>
    </ion-buttons>
    <ion-title>{{ movie()?.title }}</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  @if (movie(); as movie) {
  <ion-card>
    <div
      [style.height.px]="300"
      [style.background-image]="'url(' + imageBaseUrl + '/w400' + movie?.poster_path + ')'"></div>

    <ion-card-header>
      <ion-card-title> {{ movie?.title }} </ion-card-title>
      <ion-card-subtitle> {{ movie.tagline }} </ion-card-subtitle>
      <ion-text color="tertiary">
        @for (g of movie.genres; track g.id; let isLast = $last;) {
        <span> {{ g.name }} {{ !isLast ? '·' : '' }}</span>
        }
      </ion-text>
    </ion-card-header>
    <ion-card-content>
      <ion-label color="medium">{{ movie.overview }}</ion-label>

      <ion-item lines="none">
        <ion-icon name="calendar-outline" slot="start"></ion-icon>
        <ion-label>{{ movie.release_date | date: 'y'}}</ion-label>
      </ion-item>

      <ion-item lines="none">
        <ion-icon name="cash-outline" slot="start"></ion-icon>
        <ion-label>{{ movie.budget| currency: 'USD' }}</ion-label>
      </ion-item>
    </ion-card-content>
  </ion-card>
  }
</ion-content>

Hidden inside is also another iteration over the genres of the movie. As you can see, stuff like isLast is still available just like before!

That means, everything you learned about Angular in the past is still valuable and can be used with the new Angular 17 features.

Working with Deferred Blocks

The last thing we want to do is to add a new page that uses a deferred block – one of the coolest additions of Angular 17!

A deferred block is a block of code that is only rendered when a condition is met.

That means, we can use it to show a loading indicator or skeleton while we are loading data, and then show the actual data when it’s available.

Ok, that’s nothing new. We could do that before, right?

Well, the great thing is that now the deferred template is not loaded until required!

This means, the items will not appear in the DOM before, which can massively improve performance.

There’s even a block @loading which describes the transition from empty state to the actual data, which makes it great for SSR. Let’s build a new page that uses a deferred block to show a list of trending movies, based on the code from our previous page.

Bring up the src/app/home-defer/home-defer.page.html file and change it to:

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

<ion-content>
  @defer (when !isLoading) {
  <ion-list>
    @for (item of movies; track item.id) {
    <ion-item button [routerLink]="['/details', item.id]">
      <ion-avatar slot="start">
        <img [src]="imageBaseUrl + '/w92' + item.poster_path" alt="poster" />
      </ion-avatar>

      <ion-label class="ion-text-wrap">
        <h3>{{ item.title }}</h3>
        <p>{{ item.release_date | date:'y' }}</p>
      </ion-label>

      <ion-badge slot="end"> {{ item.vote_average }} </ion-badge>
    </ion-item>
    } 
    @empty { 
      @if (!isLoading) {
      <ion-item lines="none">
        <ion-label class="ion-text-center">No movies found</ion-label>
      </ion-item>
      } 
    }
  </ion-list>

  <ion-infinite-scroll (ionInfinite)="loadMore($event)">
    <ion-infinite-scroll-content
      loadingSpinner="bubbles"
      loadingText="Loading more data..."
    />
  </ion-infinite-scroll>
  } 
  @placeholder {
    <ion-list class="ion-padding-top">
      @for (i of dummyArray; track i) {
      <ion-item lines="none" class="ion-padding-bottom">
        <ion-avatar slot="start">
          <ion-skeleton-text></ion-skeleton-text>
        </ion-avatar>
        <ion-skeleton-text animated style="height: 40px" />
      </ion-item>
    }
  </ion-list>
  } 
  @error {
    <ion-alert
      header="Error"
      [message]="error"
      isOpen="true"
      [buttons]="['Ok']"
    />
  } 
  @loading(minimum 2s) { 
    Transition to list.... 
  }
</ion-content>

It’s mostly the same, but we now use the @defer directive to only render the list and infinite scroll when we are not loading anymore.

Additionally, we use the @placeholder directive to show the skeleton items while we are loading.

The @error directive is used to show the error message, and the @loading directive is used to show a transition message while we go from placeholder to deferred block.

Things like this make Angular 17 a great choice for building modern apps, and Ionic remains the most popular UI framework to build mobile apps with Angular and Capacitor!

Conclusion

In this tutorial, we learned how to use the new Angular Control Flow, Signals, and Deferred Blocks to build a modern Angular app with Ionic.We also learned how to use the new Ionic standalone components to only include the components we actually use in our app.

Ionic is ready for the latest updates of Angular 17, and if you want to get the most performance out of your app, you should definitely give it a try and upgrade your apps to the latest version!

If you want to learn more about Ionic, make sure to check out the Ionic Academy, an online school with 70+ video courses focused entirely on building awesome mobile apps with Ionic and Capacitor.


Simon Grimm