Published: May 16 2023

Angular 15/16 Free Course #7 - Migrate to Standalone Components and Functional Interceptors

Built and tested with Angular 15 and Angular 16

In this free step by step Angular course we'll be covering how to implement routing, authentication, registration and CRUD functionality in Angular.

Other parts available in this Angular course:


Angular Tutorial Part 7

In part 7 of this Angular tutorial series we're going to convert our project to use the new standalone components feature.

Angular Standalone Components

Standalone components aim to reduce the amount of boilerplate code required by removing the need to use Angular modules (@NgModule). Standalone components (and directives + pipes) can be declared by adding the standalone: true property to the @Component decorator. Dependencies are added directly to standalone components with an imports array, removing the need for an Angular module.

Functional Interceptors and Route Guards

We'll also simplify our HTTP interceptors and route guards by converting them from classes to simple functions. It's no longer necessary to create a class that implements the HttpInterceptor or CanActivate interface.

Code on GitHub

The complete source code for this part of the tutorial is available on GitHub at https://github.com/cornflourblue/angular-16-tutorial in the part-7 folder. If you haven't completed Part 6 (User Management (CRUD) Section) but want to follow the steps in this part of the course you can start with the code in the part-6 folder of the GitHub repo.


Tutorial Steps

  1. Update Angular Components to Standalone
  2. Update to Functional HTTP Interceptors
  3. Update to Functional Router Guard
  4. Configure APP_ROUTES and USERS_ROUTES
  5. Switch to Standalone Bootstrapping in main.ts
  6. Delete Old Angular Module (NgModule) Files
  7. Start the Angular Standalone Component App


Update Angular Components to Standalone

First we'll update all of our Angular components to be standalone by adding the standalone: true property to the @Component decorator and including any dependencies in the imports array.

It's possible to partly automate this process with an Angular schematic (ng generate @angular/core:standalone), but we'll be doing it manually to go through all the changes step by step. For more info on migrating with the Angular CLI see https://angular.io/guide/standalone-migration.

Standalone App Component

This is the app component (/src/app/app.component.ts) after converting to standalone, the @Component decorator now has the standalone: true property and the imports array with all dependencies of the component. The rest of the component is unchanged.

import { Component } from '@angular/core';
import { NgIf } from '@angular/common';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';

import { AccountService } from './_services';
import { User } from './_models';
import { AlertComponent } from './_components/alert.component';

@Component({
    selector: 'app-root', templateUrl: 'app.component.html',
    standalone: true,
    imports: [NgIf, RouterOutlet, RouterLink, RouterLinkActive, AlertComponent]
})
export class AppComponent {
    user?: User | null;

    constructor(private accountService: AccountService) {
        this.accountService.user.subscribe(x => this.user = x);
    }

    logout() {
        this.accountService.logout();
    }
}


Standalone Alert Component

This is the alert component (/src/app/_components/alert.component.ts) after converting to standalone, the @Component decorator now has the standalone: true property and the imports array with all dependencies of the component.

import { Component, OnInit, OnDestroy } from '@angular/core';
import { NgIf, NgClass } from '@angular/common';
import { Subscription } from 'rxjs';

import { AlertService } from '@app/_services';

@Component({
    selector: 'alert', templateUrl: 'alert.component.html',
    standalone: true,
    imports: [NgIf, NgClass]
})
export class AlertComponent implements OnInit, OnDestroy {
    private subscription!: Subscription;
    alert: any;

    constructor(private alertService: AlertService) { }

    ngOnInit() {
        this.subscription = this.alertService.onAlert()
            .subscribe(alert => {
                switch (alert?.type) {
                    case 'success':
                        alert.cssClass = 'alert alert-success';
                        break;
                    case 'error':
                        alert.cssClass = 'alert alert-danger';
                        break;
                }

                this.alert = alert;
            });
    }

    ngOnDestroy() {
        this.subscription.unsubscribe();
    }
}


Standalone Login Component

This is the login component (/src/app/account/login.component.ts) after converting to standalone, the @Component decorator now has the standalone: true property and the imports array with all dependencies of the component.

import { Component, OnInit } from '@angular/core';
import { NgClass, NgIf } from '@angular/common';
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { first } from 'rxjs/operators';

import { AccountService, AlertService } from '@app/_services'

@Component({
    templateUrl: 'login.component.html',
    standalone: true,
    imports: [ReactiveFormsModule, NgClass, NgIf, RouterLink]
})
export class LoginComponent implements OnInit {
    form!: FormGroup;
    loading = false;
    submitted = false;

    constructor(
        private formBuilder: FormBuilder,
        private route: ActivatedRoute,
        private router: Router,
        private accountService: AccountService,
        private alertService: AlertService
    ) {
        // redirect to home if already logged in
        if (this.accountService.userValue) {
            this.router.navigate(['/']);
        }
    }

    ngOnInit() {
        this.form = this.formBuilder.group({
            username: ['', Validators.required],
            password: ['', Validators.required]
        });
    }

    // convenience getter for easy access to form fields
    get f() { return this.form.controls; }

    onSubmit() {
        this.submitted = true;

        // reset alerts on submit
        this.alertService.clear();

        // stop here if form is invalid
        if (this.form.invalid) {
            return;
        }

        this.loading = true;
        this.accountService.login(this.f.username.value, this.f.password.value)
            .pipe(first())
            .subscribe({
                next: () => {
                    // get return url from query parameters or default to home page
                    const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
                    this.router.navigateByUrl(returnUrl);
                },
                error: error => {
                    this.alertService.error(error);
                    this.loading = false;
                }
            });
    }
}


Standalone Register Component

This is the register component (/src/app/account/register.component.ts) after converting to standalone, the @Component decorator now has the standalone: true property and the imports array with all dependencies of the component.

import { Component, OnInit } from '@angular/core';
import { NgClass, NgIf } from '@angular/common';
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { first } from 'rxjs/operators';

import { AccountService, AlertService } from '@app/_services';

@Component({
    templateUrl: 'register.component.html',
    standalone: true,
    imports: [ReactiveFormsModule, NgClass, NgIf, RouterLink]
})
export class RegisterComponent implements OnInit {
    form!: FormGroup;
    loading = false;
    submitted = false;

    constructor(
        private formBuilder: FormBuilder,
        private route: ActivatedRoute,
        private router: Router,
        private accountService: AccountService,
        private alertService: AlertService
    ) {
        // redirect to home if already logged in
        if (this.accountService.userValue) {
            this.router.navigate(['/']);
        }
    }

    ngOnInit() {
        this.form = this.formBuilder.group({
            firstName: ['', Validators.required],
            lastName: ['', Validators.required],
            username: ['', Validators.required],
            password: ['', [Validators.required, Validators.minLength(6)]]
        });
    }

    // convenience getter for easy access to form fields
    get f() { return this.form.controls; }

    onSubmit() {
        this.submitted = true;

        // reset alert on submit
        this.alertService.clear();

        // stop here if form is invalid
        if (this.form.invalid) {
            return;
        }

        this.loading = true;
        this.accountService.register(this.form.value)
            .pipe(first())
            .subscribe({
                next: () => {
                    this.alertService.success('Registration successful', true);
                    this.router.navigate(['/account/login'], { queryParams: { registered: true }});
                },
                error: error => {
                    this.alertService.error(error);
                    this.loading = false;
                }
            });
    }
}


Standalone Home Component

This is the home component (/src/app/home/home.component.ts) after converting to standalone, the @Component decorator now has the standalone: true property. It doesn't depend on any other components.

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

import { User } from '@app/_models';
import { AccountService } from '@app/_services';

@Component({
    templateUrl: 'home.component.html',
    standalone: true
})
export class HomeComponent {
    user: User | null;

    constructor(private accountService: AccountService) {
        this.user = this.accountService.userValue;
    }
}


Standalone Add/Edit User Component

This is the add/edit user component (/src/app/users/add-edit.component.ts) after converting to standalone, the @Component decorator now has the standalone: true property and the imports array with all dependencies of the component.

import { Component, OnInit } from '@angular/core';
import { NgIf, NgClass } from '@angular/common';
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { first } from 'rxjs/operators';

import { AccountService, AlertService } from '@app/_services';

@Component({
    templateUrl: 'add-edit.component.html',
    standalone: true,
    imports: [NgIf, ReactiveFormsModule, NgClass, RouterLink]
})
export class AddEditComponent implements OnInit {
    form!: FormGroup;
    id?: string;
    title!: string;
    loading = false;
    submitting = false;
    submitted = false;

    constructor(
        private formBuilder: FormBuilder,
        private route: ActivatedRoute,
        private router: Router,
        private accountService: AccountService,
        private alertService: AlertService
    ) { }

    ngOnInit() {
        this.id = this.route.snapshot.params['id'];

        // form with validation rules
        this.form = this.formBuilder.group({
            firstName: ['', Validators.required],
            lastName: ['', Validators.required],
            username: ['', Validators.required],
            // password only required in add mode
            password: ['', [Validators.minLength(6), ...(!this.id ? [Validators.required] : [])]]
        });

        this.title = 'Add User';
        if (this.id) {
            // edit mode
            this.title = 'Edit User';
            this.loading = true;
            this.accountService.getById(this.id)
                .pipe(first())
                .subscribe(x => {
                    this.form.patchValue(x);
                    this.loading = false;
                });
        }
    }

    // convenience getter for easy access to form fields
    get f() { return this.form.controls; }

    onSubmit() {
        this.submitted = true;

        // reset alerts on submit
        this.alertService.clear();

        // stop here if form is invalid
        if (this.form.invalid) {
            return;
        }

        this.submitting = true;
        this.saveUser()
            .pipe(first())
            .subscribe({
                next: () => {
                    this.alertService.success('User saved', true);
                    this.router.navigateByUrl('/users');
                },
                error: error => {
                    this.alertService.error(error);
                    this.submitting = false;
                }
            })
    }

    private saveUser() {
        // create or update user based on id param
        return this.id
            ? this.accountService.update(this.id!, this.form.value)
            : this.accountService.register(this.form.value);
    }
}


Standalone List Users Component

This is the list users component (/src/app/users/list.component.ts) after converting to standalone, the @Component decorator now has the standalone: true property and the imports array with all dependencies of the component.


import { Component, OnInit } from '@angular/core';
import { NgFor, NgIf } from '@angular/common';
import { RouterLink } from '@angular/router';
import { first } from 'rxjs/operators';

import { AccountService } from '@app/_services';

@Component({
    templateUrl: 'list.component.html',
    standalone: true,
    imports: [RouterLink, NgFor, NgIf]
})
export class ListComponent implements OnInit {
    users?: any[];

    constructor(private accountService: AccountService) {}

    ngOnInit() {
        this.accountService.getAll()
            .pipe(first())
            .subscribe(users => this.users = users);
    }

    deleteUser(id: string) {
        const user = this.users!.find(x => x.id === id);
        user.isDeleting = true;
        this.accountService.delete(id)
            .pipe(first())
            .subscribe(() => this.users = this.users!.filter(x => x.id !== id));
    }
}


Update to Functional HTTP Interceptors

Next we'll simplify our HTTP interceptors by converting them from classes to simple functions.

Functional JWT Interceptor

This is the JWT interceptor (/src/app/_helpers/jwt.interceptor.ts) after converting to a simple function. It was previously a class that implemented the HttpInterceptor interface and had an intercept() method, the method has been converted into the jwtInterceptor() function.

We're using the new inject() function to inject the AccountService dependency, since there is no longer a class constructor() to inject dependencies.

import { inject } from '@angular/core';
import { HttpRequest, HttpHandlerFn } from '@angular/common/http';

import { environment } from '@environments/environment';
import { AccountService } from '@app/_services';

export function jwtInterceptor(request: HttpRequest<any>, next: HttpHandlerFn) {
    // add auth header with jwt if user is logged in and request is to the api url
    const accountService = inject(AccountService);
    const user = accountService.userValue;
    const isLoggedIn = user?.token;
    const isApiUrl = request.url.startsWith(environment.apiUrl);
    if (isLoggedIn && isApiUrl) {
        request = request.clone({
            setHeaders: { Authorization: `Bearer ${user.token}` }
        });
    }

    return next(request);
}


Functional Error Interceptor

This is the HTTP error interceptor (/src/app/_helpers/error.interceptor.ts) after converting to a simple function. It was previously a class that implemented the HttpInterceptor interface and had an intercept() method, the method has been converted into the errorInterceptor() function.

import { inject } from '@angular/core';
import { HttpRequest, HttpHandlerFn } from '@angular/common/http';
import { throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

import { AccountService } from '@app/_services';

export function errorInterceptor(request: HttpRequest<any>, next: HttpHandlerFn) {
    const accountService = inject(AccountService);
    return next(request).pipe(catchError(err => {
        if ([401, 403].includes(err.status) && accountService.userValue) {
            // auto logout if 401 or 403 response returned from api
            accountService.logout();
        }

        const error = err.error?.message || err.statusText;
        console.error(err);
        return throwError(() => error);
    }))
}


Functional Fake Backend Interceptor

This is the fake backend HTTP interceptor (/src/app/_helpers/fake-backend.ts) after converting to a simple function. It was previously a class that implemented the HttpInterceptor interface and had an intercept() method, the method has been converted into the fakeBackendInterceptor() function.

import { HttpRequest, HttpResponse, HttpHandlerFn } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { delay, materialize, dematerialize } from 'rxjs/operators';

// array in local storage for registered users
const usersKey = 'angular-tutorial-users';
let users: any[] = JSON.parse(localStorage.getItem(usersKey)!) || [];

export function fakeBackendInterceptor(request: HttpRequest<any>, next: HttpHandlerFn) {
    const { url, method, headers, body } = request;

    return handleRoute();

    function handleRoute() {
        switch (true) {
            case url.endsWith('/users/authenticate') && method === 'POST':
                return authenticate();
            case url.endsWith('/users/register') && method === 'POST':
                return register();
            case url.endsWith('/users') && method === 'GET':
                return getUsers();
            case url.match(/\/users\/\d+$/) && method === 'GET':
                return getUserById();
            case url.match(/\/users\/\d+$/) && method === 'PUT':
                return updateUser();
            case url.match(/\/users\/\d+$/) && method === 'DELETE':
                return deleteUser();
            default:
                // pass through any requests not handled above
                return next(request);
        }
    }

    // route functions

    function authenticate() {
        const { username, password } = body;
        const user = users.find(x => x.username === username && x.password === password);
        if (!user) return error('Username or password is incorrect');
        return ok({
            ...basicDetails(user),
            token: 'fake-jwt-token'
        })
    }

    function register() {
        const user = body

        if (users.find(x => x.username === user.username)) {
            return error('Username "' + user.username + '" is already taken')
        }

        user.id = users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
        users.push(user);
        localStorage.setItem(usersKey, JSON.stringify(users));
        return ok();
    }

    function getUsers() {
        if (!isLoggedIn()) return unauthorized();
        return ok(users.map(x => basicDetails(x)));
    }

    function getUserById() {
        if (!isLoggedIn()) return unauthorized();

        const user = users.find(x => x.id === idFromUrl());
        return ok(basicDetails(user));
    }

    function updateUser() {
        if (!isLoggedIn()) return unauthorized();

        let params = body;
        let user = users.find(x => x.id === idFromUrl());

        // only update password if entered
        if (!params.password) {
            delete params.password;
        }

        // update and save user
        Object.assign(user, params);
        localStorage.setItem(usersKey, JSON.stringify(users));

        return ok();
    }

    function deleteUser() {
        if (!isLoggedIn()) return unauthorized();

        users = users.filter(x => x.id !== idFromUrl());
        localStorage.setItem(usersKey, JSON.stringify(users));
        return ok();
    }

    // helper functions

    function ok(body?: any) {
        return of(new HttpResponse({ status: 200, body }))
            .pipe(delay(500)); // delay observable to simulate server api call
    }

    function error(message: string) {
        return throwError(() => ({ error: { message } }))
            .pipe(materialize(), delay(500), dematerialize()); // call materialize and dematerialize to ensure delay even if an error is thrown (https://github.com/Reactive-Extensions/RxJS/issues/648);
    }

    function unauthorized() {
        return throwError(() => ({ status: 401, error: { message: 'Unauthorized' } }))
            .pipe(materialize(), delay(500), dematerialize());
    }

    function basicDetails(user: any) {
        const { id, username, firstName, lastName } = user;
        return { id, username, firstName, lastName };
    }

    function isLoggedIn() {
        return headers.get('Authorization') === 'Bearer fake-jwt-token';
    }

    function idFromUrl() {
        const urlParts = url.split('/');
        return parseInt(urlParts[urlParts.length - 1]);
    }
}


Update to Functional Router Guard

Next we'll simplify our router guard by converting it from a class to a simple functions.

Functional Auth Guard

This is the auth guard (/src/app/_helpers/auth.guard.ts) after converting to a simple function. It was previously a class that implemented the CanActivate interface and had a canActivate() method, the method has been converted into the authGuard() function.

We're using the new inject() function to inject the Router and AccountService dependencies, since there is no longer a class constructor() to inject dependencies.

import { inject } from '@angular/core';
import { Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';

import { AccountService } from '@app/_services';

export function authGuard(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
    const router = inject(Router);
    const accountService = inject(AccountService);
    const user = accountService.userValue;
    if (user) {
        // authorised so return true
        return true;
    }

    // not logged in so redirect to login page with the return url
    router.navigate(['/account/login'], { queryParams: { returnUrl: state.url } });
    return false;
}


Configure APP_ROUTES and USERS_ROUTES

Now we'll simplify our route configuration by converting them from routing modules to simple [feature]_ROUTES objects.

Users Routes Object

This is the new config file for users routes (/src/app/users/users.routes.ts). The routes were previously configured in the users routing module (/src/app/users/users-routing.module.ts) and have been replaced by the simple USERS_ROUTES object.

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

import { ListComponent } from './list.component';
import { AddEditComponent } from './add-edit.component';

export const USERS_ROUTES: Routes = [
    { path: '', component: ListComponent },
    { path: 'add', component: AddEditComponent },
    { path: 'edit/:id', component: AddEditComponent }
];


App Routes Object

This is the new config file for the base app routes (/src/app/app.routes.ts). The routes were previously configured in the app routing module (/src/app/app-routing.module.ts) and have been replaced by the simple APP_ROUTES object.

The users routes are lazy loaded with the callback function assigned to loadChildren in the route config.

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

import { HomeComponent } from './home';
import { LoginComponent, RegisterComponent } from './account';
import { authGuard } from './_helpers';

const usersRoutes = () => import('./users/users.routes').then(x => x.USERS_ROUTES);

export const APP_ROUTES: Routes = [
    { path: '', component: HomeComponent, canActivate: [authGuard] },
    { path: 'users', loadChildren: usersRoutes, canActivate: [authGuard] },
    { path: 'account/login', component: LoginComponent },
    { path: 'account/register', component: RegisterComponent },

    // otherwise redirect to home
    { path: '**', redirectTo: '' }
];


Switch to Standalone Bootstrapping in main.ts

Now we'll update the Angular app to standalone bootstrapping by passing a standalone component to the bootstrapApplication() function instead of calling platformBrowserDynamic().bootstrapModule(AppModule).

Standalone Bootstrapping

This is the updated main.ts file that bootstraps our standalone AppComponent to start the Angular app, it previously started the app by bootstrapping the AppModule.

The providers array adds support for Router and HttpClient functionality to the standalone app. Routes are configured by passing the APP_ROUTES object to provideRouter(), and HTTP interceptors are configured by passing the withInterceptors() function to provideHttpClient().

import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';

// fake backend
import { fakeBackendInterceptor } from '@app/_helpers';

import { AppComponent } from '@app/app.component';
import { jwtInterceptor, errorInterceptor } from '@app/_helpers';
import { APP_ROUTES } from '@app/app.routes';

bootstrapApplication(AppComponent, {
    providers: [
        provideRouter(APP_ROUTES),
        provideHttpClient(
            withInterceptors([
                jwtInterceptor, 
                errorInterceptor,

                // fake backend
                fakeBackendInterceptor
            ])
        )
    ]
});


Delete Old Angular Module (NgModule) Files

Finally delete our old Angular modules as they are no longer needed:

  • App Module - /src/app/app.module.ts
  • App Routing Module - /src/app/app-routing.module.ts
  • Users Module - /src/app/users/users.module.ts
  • Users Routing Module - /src/app/users/users-routing.module.ts


Start the Angular Standalone Component App!

Run the command npm start from the project root folder (where the package.json is located) to start the Angular application, then open a browser tab to the URL http://localhost:4200.

The command output should look something like this:

PS C:\Projects\angular-tutorial> npm start

> [email protected] start
> ng serve

✔ Browser application bundle generation complete.

Initial Chunk Files              | Names              |  Raw Size
vendor.js                        | vendor             |   2.56 MB |
polyfills.js                     | polyfills          | 328.83 kB |
styles.css, styles.js            | styles             | 226.27 kB |
main.js                          | main               |  59.00 kB |
runtime.js                       | runtime            |  12.64 kB |

                                 | Initial Total      |   3.17 MB

Lazy Chunk Files                 | Names              |  Raw Size
src_app_users_users_routes_ts.js | users-users-routes |  27.79 kB |

Build at: 2023-05-16T02:30:52.579Z - Hash: 19d712bf978f2bdd - Time: 11141ms

** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **


√ Compiled successfully

 


Need Some Angular 16 Help?

Search fiverr for freelance Angular 16 developers.


Follow me for updates

On Twitter or RSS.


When I'm not coding...

Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!


Comments


Supported by