Here’s what our application architecture looked like at the end of Chapter 3:
In this chapter we will:
By the end of this article, you’ll understand:
So, let’s get started!
In its current state, our web application does not take the browser URL into account.
We access our application through one URL such as http://localhost:4200
and our application is not aware of any other URLs such as http://localhost:4200/todos
.
Most web applications need to support different URLs to navigate users to different pages in the application. That’s where a router comes in.
In traditional websites, routing is handled by a router on the server:
In modern JavaScript web applications, routing is often handled by a JavaScript router in the browser.
In essence, a JavaScript router does two things:
JavaScript routers make it possible for us to develop single-page applications (SPAs).
An SPA is a web application that provides a user experience similar to a desktop application. In an SPA, all communication with a back end occurs behind the scenes.
When a user navigates from one page to another, the page is updated dynamically without reload, even if the URL changes.
There are many different JavaScript router implementations available.
Some of them are specifically written for a certain JavaScript framework such as Angular, Ember, React, Vue.js and Aurelia, etc. Other implementations are built for generic purposes and aren’t tied to a specific framework.
Angular Router is an official Angular routing library, written and maintained by the Angular Core Team.
It’s a JavaScript router implementation that’s designed to work with Angular and is packaged as @angular/router
.
First of all, Angular Router takes care of the duties of a JavaScript router:
In addition, Angular Router allows us to:
In this article, we’ll learn how to set up and configure Angular Router, how to redirect a URL and how to use Angular Router to resolve todos from our back-end API.
In the next article, we’ll add authentication to our application and use the router to make sure some of the pages can only be accessed when the user is signed in.
Before we dive into the code, it’s important to understand how Angular Router operates and the terminology it introduces.
When a user navigates to a page, Angular Router performs the following steps in order:
To accomplish its tasks, Angular Router introduces the following terms and concepts:
Don’t worry if the terminology sounds overwhelming. You’ll get used to the terms as we tackle them gradually in this series and as you gain more experience with Angular Router.
An Angular application that uses Angular Router only has one router service instance: It’s a singleton. Whenever and wherever you inject the Router
service in your application, you’ll get access to the same Angular Router service instance.
For a more in-depth look at Angular routing process, make sure to check out the 7-step routing process of Angular Router navigation.
To enable routing in our Angular application, we need to do three things:
So let’s start by creating a routing configuration.
To create our routing configuration, we need a list of the URLs we’d like our application to support.
Currently, our application is very simple and only has one page that shows a list of todos:
/
: show list of todoswhich would show the list of todos as the home page of our application.
However, when a user bookmarks /
in their browser to consult their list of todos and we change the contents of our home page (which we’ll do in part 5 of this series), their bookmark would no longer show their list of todos.
So let’s give our todo list its own URL and redirect our home page to it:
/
: redirect to /todos
/todos
: show list of todos.This provides us with two benefits:
/todos
instead of /
, which will keep working as expected, even if we change the home page contentsThe official Angular style guide recommends storing the routing configuration for an Angular module in a file with a filename ending in -routing.module.ts
that exports a separate Angular module with a name ending in RoutingModule
.
Our current module is called AppModule
, so we create a file src/app/app-routing.module.ts
and export our routing configuration as an Angular module called AppRoutingModule
:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AppComponent } from './app.component';
const routes: Routes = [
{
path: '',
redirectTo: 'todos',
pathMatch: 'full'
},
{
path: 'todos',
component: AppComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
providers: []
})
export class AppRoutingModule {
}
First we import RouterModule
and Routes
from @angular/router
:
import { RouterModule, Routes } from '@angular/router';
Next, we define a variable routes
of type Routes
and assign it our router configuration:
const routes: Routes = [
{
path: '',
redirectTo: 'todos',
pathMatch: 'full'
},
{
path: 'todos',
component: AppComponent
}
];
The Routes
type is optional and lets an IDE with TypeScript support or the TypeScript compiler conveniently validate your route configuration during development.
The router configuration represents all possible router states our application can be in.
It is a tree of routes, defined as a JavaScript array, where each route can have the following properties:
Our application is simple and only contains two sibling routes, but a larger application could have a router configuration with child routes such as:
const routes: Routes = [
{
path: '',
redirectTo: 'todos',
pathMatch: 'full'
},
{
path: 'todos',
children: [
{
path: '',
component: 'TodosPageComponent'
},
{
path: ':id',
component: 'TodoPageComponent'
}
]
}
];
Here, todos
has two child routes and :id
is a route parameter, enabling the router to recognize the following URLs:
/
: home page, redirect to /todos
/todos
: activate TodosPageComponent
and show list of todos/todos/1
: activate TodoPageComponent
and set value of :id
parameter to 1
/todos/2
: activate TodoPageComponent
and set value of :id
parameter to 2
.Notice how we specify patchMatch: 'full'
when defining the redirect.
Angular Router has two matching strategies:
path
path
.We can create the following route:
// no pathMatch specified, so Angular Router applies
// the default `prefix` pathMatch
{
path: '',
redirectTo: 'todos'
}
In this case, Angular Router applies the default prefix
path matching strategy and every URL is redirected to todos
because every URL starts with the empty string ''
specified in path
.
We only want our home page to be redirected to todos
, so we add pathMatch: 'full'
to make sure that only the URL that equals the empty string ''
is matched:
{
path: '',
redirectTo: 'todos',
pathMatch: 'full'
}
To learn more about the different routing configuration options, check out the official Angular documentation on Routing and Navigation.
Finally, we create and export an Angular module AppRoutingModule
:
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
providers: []
})
export class AppRoutingModule {
}
There are two ways to create a routing module:
RouterModule.forRoot(routes)
: creates a routing module that includes the router directives, the route configuration and the router serviceRouterModule.forChild(routes)
: creates a routing module that includes the router directives, the route configuration but not the router service.The RouterModule.forChild()
method is needed when your application has multiple routing modules.
Remember that the router service takes care of synchronization between our application state and the browser URL. Instantiating multiple router services that interact with the same browser URL would lead to issues, so it is essential that there’s only one instance of the router service in our application, no matter how many routing modules we import in our application.
When we import a routing module that is created using RouterModule.forRoot()
, Angular will instantiate the router service. When we import a routing module that’s created using RouterModule.forChild()
, Angular will not instantiate the router service.
Therefore we can only use RouterModule.forRoot()
once and use RouterModule.forChild()
multiple times for additional routing modules.
Because our application only has one routing module, we use RouterModule.forRoot()
:
imports: [RouterModule.forRoot(routes)]
In addition, we also specify RouterModule
in the exports
property:
exports: [RouterModule]
This ensures that we don’t have to explicitly import RouterModule
again in AppModule
when AppModule
imports AppRoutingModule
.
Now that we have our AppRoutingModule
, we need to import it in our AppModule
to enable it.
To import our routing configuration into our application, we must import AppRoutingModule
into our main AppModule
.
Let’s open up src/app/app.module.ts
and add AppRoutingModule
to the imports
array in AppModule
’s @NgModule
metadata:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { AppComponent } from './app.component';
import { TodoListComponent } from './todo-list/todo-list.component';
import { TodoListFooterComponent } from './todo-list-footer/todo-list-footer.component';
import { TodoListHeaderComponent } from './todo-list-header/todo-list-header.component';
import { TodoDataService } from './todo-data.service';
import { TodoListItemComponent } from './todo-list-item/todo-list-item.component';
import { ApiService } from './api.service';
import { AppRoutingModule } from './app-routing.module';
@NgModule({
declarations: [
AppComponent,
TodoListComponent,
TodoListFooterComponent,
TodoListHeaderComponent,
TodoListItemComponent
],
imports: [
AppRoutingModule,
BrowserModule,
FormsModule,
HttpModule
],
providers: [TodoDataService, ApiService],
bootstrap: [AppComponent]
})
export class AppModule {
}
Because AppRoutingModule
has RoutingModule
listed in its exports
property, Angular will import RoutingModule
automatically when we import AppRoutingModule
, so we don’t have to explicitly import RouterModule
again (although doing so would not cause any harm).
Before we can try out our changes in the browser, we need to complete the third and final step.
Although our application now has a routing configuration, we still need to tell Angular Router where it can place the instantiated components in the DOM.
When our application is bootstrapped, Angular instantiates AppComponent
because AppComponent
is listed in the bootstrap
property of AppModule
:
@NgModule({
// ...
bootstrap: [AppComponent]
})
export class AppModule {
}
To tell Angular Router where it can place components, we must add the <router-outlet></router-outlet>
element to AppComponent
’s HTML template.
The <router-outlet></router-outlet>
element tells Angular Router where it can instantiate components in the DOM.
If you’re familiar AngularJS 1.x router and UI-Router, you can consider <router-outlet></router-outlet>
the Angular alternative to ng-view
and ui-view
.
Without a <router-outlet></router-outlet>
element, Angular Router would not know where to place the components and only AppComponent
’s own HTML would be rendered.
AppComponent
currently displays a list of todos.
But instead of letting AppComponent
display a list of todos, we now want AppComponent
to contain a <router-outlet></router-outlet>
and tell Angular Router to instantiate another component inside AppComponent
to display the list of todos.
To accomplish that, let’s generate a new component TodosComponent
using Angular CLI:
$ ng generate component Todos
Let’s also move all HTML from src/app/app.component.html
to src/app/todos/todos.component.html
:
<!-- src/app/todos/todos.component.html -->
<section class="todoapp">
<app-todo-list-header
(add)="onAddTodo($event)"
></app-todo-list-header>
<app-todo-list
[todos]="todos"
(toggleComplete)="onToggleTodoComplete($event)"
(remove)="onRemoveTodo($event)"
></app-todo-list>
<app-todo-list-footer
[todos]="todos"
></app-todo-list-footer>
</section>
Let’s also move all logic from src/app/app.component.ts
to src/app/todos/todos.component.ts
:
/* src/app/todos/todos.component.ts */
import { Component, OnInit } from '@angular/core';
import { TodoDataService } from '../todo-data.service';
import { Todo } from '../todo';
@Component({
selector: 'app-todos',
templateUrl: './todos.component.html',
styleUrls: ['./todos.component.css'],
providers: [TodoDataService]
})
export class TodosComponent implements OnInit {
todos: Todo[] = [];
constructor(
private todoDataService: TodoDataService
) {
}
public ngOnInit() {
this.todoDataService
.getAllTodos()
.subscribe(
(todos) => {
this.todos = todos;
}
);
}
onAddTodo(todo) {
this.todoDataService
.addTodo(todo)
.subscribe(
(newTodo) => {
this.todos = this.todos.concat(newTodo);
}
);
}
onToggleTodoComplete(todo) {
this.todoDataService
.toggleTodoComplete(todo)
.subscribe(
(updatedTodo) => {
todo = updatedTodo;
}
);
}
onRemoveTodo(todo) {
this.todoDataService
.deleteTodoById(todo.id)
.subscribe(
(_) => {
this.todos = this.todos.filter((t) => t.id !== todo.id);
}
);
}
}
Now we can replace AppComponent
’s template in src/app/app.component.html
with:
<router-outlet></router-outlet>
We can also remove all obsolete code from AppComponent
’s class in src/app/app.component.ts
:
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
}
Finally, we update our todos
route in src/app/app-routing.module.ts
to instantiate TodosComponent
instead of AppComponent
:
const routes: Routes = [
{
path: '',
redirectTo: 'todos',
pathMatch: 'full'
},
{
path: 'todos',
component: TodosComponent
}
];
Now, when our application is bootstrapped, Angular instantiates AppComponent
and finds a <router-outlet></router-outlet>
where Angular Router can instantiate and activate components.
Let’s try out our changes in the browser.
Start your development server and your back-end API by running:
$ ng serve
$ npm run json-server
Then navigate your browser to http://localhost:4200
.
Angular Router reads the router configuration and automatically redirects our browser to http://localhost:4200/todos
.
If you inspect the elements on the page, you’ll see that the TodosComponent
is not rendered inside <router-outlet></router-outlet>
, but right next to it:
<app-root>
<!-- Angular Router finds router outlet -->
<router-outlet></router-outlet>
<!-- and places the component right next to it, NOT inside it -->
<app-todos></app-todos>
</app-root>
Our application now has routing enabled. Awesome!
When you navigate your browser to http://localhost:4200/unmatched-url
, and you open up your browser’s developer tools, you will notice that Angular Router logs the following error to the console:
Error: Cannot match any routes. URL Segment: 'unmatched-url'
To handle unmatched URLs gracefully we need to do two things:
PageNotFoundComponent
(you can name it differently if you like) to display a friendly message that the requested page could not be foundPageNotFoundComponent
when no route matches the requested URL.Let’s start by generating PageNotFoundComponent
using Angular CLI:
$ ng generate component PageNotFound
Then edit its template in src/app/page-not-found/page-not-found.component.html
:
<p>We are sorry, the requested page could not be found.</p>
Next, we add a wildcard route using **
as a path:
const routes: Routes = [
{
path: '',
redirectTo: 'todos',
pathMatch: 'full'
},
{
path: 'todos',
component: AppComponent
},
{
path: '**',
component: PageNotFoundComponent
}
];
The **
matches any URL, including child paths.
Now, if you navigate your browser to http://localhost:4200/unmatched-url
, PageNotFoundComponent
is displayed.
Notice that the wildcard route must be the last route in our routing configuration for it to work as expected.
When Angular Router matches a request URL to the router configuration, it stops processing as soon as it finds the first match.
So if we were to change the order of the routes to this:
const routes: Routes = [
{
path: '',
redirectTo: 'todos',
pathMatch: 'full'
},
{
path: '**',
component: PageNotFoundComponent
},
{
path: 'todos',
component: AppComponent
}
];
then todos
would never be reached and PageNotFoundComponent
would be displayed because the wildcard route would be matched first.
We have already done a lot, so let’s quickly recap what we have accomplished so far:
AppComponent
to TodosComponent
<router-outlet></router-outlet>
to AppComponent
’s templateNext, we will create a resolver to fetch the existing todos from our back-end API using Angular Router.
In Chapter 3, we already learned how to fetch data from our back-end API using the Angular HTTP service.
Currently, when we navigate our browser to the todos
URL, the following happens:
todos
URLTodosComponent
TodosComponent
next to <router-outlet></router-outlet>
in the DOMTodosComponent
is displayed in the browser with an empty array of todosngOnInit
handler of theTodosComponent
TodosComponent
is updated in the browser with the todos fetched from the API.If loading the todos in step 5 takes three seconds, the user will be presented with an empty todo list for three seconds before the actual todos are displayed in step 6.
If the TodosComponent
were to have the following HTML in its template:
<div *ngIf="!todos.length">
You currently do not have any todos yet.
</div>
then the user would see this message for three seconds before the actual todos are displayed, which could totally mislead the user and cause the user to navigate away before the actual data comes in.
We could add a loader to TodosComponent
that shows a spinner while the data is being loaded, but sometimes we may not have control over the actual component, for example when we use a third-party component.
To fix this unwanted behavior, we need the following to happen:
todos
URLTodosComponent
TodosComponent
next to <router-outlet></router-outlet>
in the DOMTodosComponent
is displayed in the browser with the todos fetched from the API.Here, the TodosComponent
is not displayed until the data from our API back end is available.
That is exactly what a resolver can do for us.
To let Angular Router resolve the todos before it activates the TodosComponent
, we must do two things:
TodosResolver
that fetches the todos from the APITodosResolver
to fetch the todos when activating the TodosComponent
in the todos
route.By attaching a resolver to the todos
route we ask Angular Router to resolve the data first, before TodosComponent
is activated.
So let’s create a resolver to fetch our todos.
Angular CLI does not have a command to generate a resolver, so let’s create a new file src/todos.resolver.ts
manually and add the following code:
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Todo } from './todo';
import { TodoDataService } from './todo-data.service';
@Injectable()
export class TodosResolver implements Resolve<Observable<Todo[]>> {
constructor(
private todoDataService: TodoDataService
) {
}
public resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<Todo[]> {
return this.todoDataService.getAllTodos();
}
}
We define the resolver as a class that implements the Resolve
interface.
The Resolve
interface is optional, but lets our TypeScript IDE or compiler ensure that we implement the class correctly by requiring us to implement a resolve()
method.
When Angular Router needs to resolve data using a resolver, it calls the resolver’s resolve()
method and expects the resolve()
method to return a value, a promise, or an observable.
If the resolve()
method returns a promise or an observable Angular Router will wait for the promise or observable to complete before it activates the route’s component.
When calling the resolve()
method, Angular Router conveniently passes in the activated route snapshot and the router state snapshot to provide us with access to data (such as route parameters or query parameters) we may need to resolve the data.
The code for TodosResolver
is very concise because we already have a TodoDataService
that handles all communication with our API back end.
We inject TodoDataService
in the constructor and use its getAllTodos()
method to fetch all todos in the resolve()
method.
The resolve method returns an observable of the type Todo[]
, so Angular Router will wait for the observable to complete before the route’s component is activated.
Now that we have our resolver, let’s configure Angular Router to use it.
To make Angular Router use a resolver, we must attach it to a route in our route configuration.
Let’s open up src/app-routing.module.ts
and add our TodosResolver
to the todos
route:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
import { TodosComponent } from './todos/todos.component';
import { TodosResolver } from './todos.resolver';
const routes: Routes = [
{
path: '',
redirectTo: 'todos',
pathMatch: 'full'
},
{
path: 'todos',
component: TodosComponent,
resolve: {
todos: TodosResolver
}
},
{
path: '**',
component: PageNotFoundComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
providers: [
TodosResolver
]
})
export class AppRoutingModule {
}
We import TodosResolver
:
import { TodosResolver } from './todos.resolver';
Also add it as a resolver the the todos
route:
{
path: 'todos',
component: TodosComponent,
resolve: {
todos: TodosResolver
}
}
This tells Angular Router to resolve data using TodosResolver
and assign the resolver’s return value as todos
in the route’s data.
A route’s data can be accessed from the ActivatedRoute
or ActivatedRouteSnapshot
, which we will see in the next section.
You can add static data directly to a route’s data using the data
property of the route:
{
path: 'todos',
component: TodosComponent,
data: {
title: 'Example of static route data'
}
}
You can also add dynamic data using a resolver specified in the the resolve
property of the route:
resolve: {
path: 'todos',
component: TodosComponent,
resolve: {
todos: TodosResolver
}
}
You could also do both at the same time:
resolve: {
path: 'todos',
component: TodosComponent,
data: {
title: 'Example of static route data'
}
resolve: {
todos: TodosResolver
}
}
As soon as the resolvers from the resolve
property are resolved, their values are merged with the static data from the data
property and all data is made available as the route’s data.
Angular Router uses Angular dependency injection to access resolvers, so we have to make sure we register TodosResolver
with Angular’s dependency injection system by adding it to the providers
property in AppRoutingModule
’s @NgModule
metadata:
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
providers: [
TodosResolver
]
})
export class AppRoutingModule {
}
When you navigate your browser to http://localhost:4200
, Angular Router now:
/
to /todos
todos
route has TodosResolver
defined in its resolve
propertyresolve()
method from TodosResolver
, waits for the result and assigns the result to todos
in the route’s dataTodosComponent
.If you open up the network tab of your developer tools, you’ll see that the todos are now fetched twice from the API. Once by Angular Router and once by the ngOnInit
handler in TodosComponent
.
So Angular Router already fetches the todos from the API, but TodosComponent
still uses its own internal logic to load the todos.
In the next section, we’ll update TodosComponent
to use the data resolved by Angular Router.
Let’s open up app/src/todos/todos.component.ts
.
The ngOnInit()
handler currently fetches the todos directly from the API:
public ngOnInit() {
this.todoDataService
.getAllTodos()
.subscribe(
(todos) => {
this.todos = todos;
}
);
}
Now that Angular Router fetches the todos using TodosResolver
, we want to fetch the todos in TodosComponent
from the route data instead of the API.
To access the route data, we must import ActivatedRoute
from @angular/router
:
import { ActivatedRoute } from '@angular/router';
and use Angular dependency injection to get a handle of the activated route:
constructor(
private todoDataService: TodoDataService,
private route: ActivatedRoute
) {
}
Finally, we update the ngOnInit()
handler to get the todos from the route data instead of the API:
public ngOnInit() {
this.route.data
.map((data) => data['todos'])
.subscribe(
(todos) => {
this.todos = todos;
}
);
}
The ActivatedRoute
exposes the route data as an observable, so our code barely changes.
We replace this.todoDataService.getAllTodos()
with this.route.data.map((data) => data['todos'])
and all the rest of the code remains unchanged.
If you navigate your browser to localhost:4200
and open up the network tab, you’ll no longer see two HTTP requests fetching the todos from the API.
Mission accomplished! We’ve successfully integrated Angular Router in our application!
Before we wrap up, let’s run our unit tests:
ng serve
One unit test fails:
Executed 11 of 11 (1 FAILED)
TodosComponent should create FAILED
'app-todo-list-header' is not a known element
When TodosComponent
is tested, the testbed is not aware of TodoListHeaderComponent
and thus Angular complains that it doesn’t know the app-todo-list-header
element.
To fix this error, let’s open up app/src/todos/todos.component.spec.ts
and add NO_ERRORS_SCHEMA
to the TestBed
options:
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [TodosComponent],
schemas: [
NO_ERRORS_SCHEMA
]
})
.compileComponents();
}));
Now Karma shows another error:
Executed 11 of 11 (1 FAILED)
TodosComponent should create FAILED
No provider for ApiService!
Let’s add the necessary providers to the test bed options:
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [TodosComponent],
schemas: [
NO_ERRORS_SCHEMA
],
providers: [
TodoDataService,
{
provide: ApiService,
useClass: ApiMockService
}
],
})
.compileComponents();
}));
This again raises another error:
Executed 11 of 11 (1 FAILED)
TodosComponent should create FAILED
No provider for ActivatedRoute!!
Let’s add one more provider for ActivatedRoute
to the testbed options:
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [TodosComponent],
schemas: [
NO_ERRORS_SCHEMA
],
providers: [
TodoDataService,
{
provide: ApiService,
useClass: ApiMockService
},
{
provide: ActivatedRoute,
useValue: {
data: Observable.of({
todos: []
})
}
}
],
})
.compileComponents();
}));
We assign the provider for ActivatedRoute
a mock object that contains an observable data property to expose a test value for todos
.
Now the unit tests successfully pass:
Executed 11 of 11 SUCCESS
Fabulous! To deploy our application to a production environment, we can now run:
ng build --aot --environment prod
We upload the generated dist
directory to our hosting server. How sweet is that?
We’ve covered a lot in this article, so let’s recap what we have learned.
In this chapter, we learned:
All code from this chapter is available at GitHub.
In Chapter 5, we’ll implement authentication to prevent unauthorized access to our application.