Skip to content

Routing to a Detail Page

We’re making some great progress now. We have the ability to create a todo, it can be stored as part of our application’s “state” in memory using our service, and we can display todos stored in that service in our HomeComponent.

At the moment, we are only displaying the todos title property. Now we are going to create a detail page so that we can click on any of our todos to view its full details in another page. As I pointed out before, this is a bit of a silly example because there is such a small amount of information to see that we should probably just have it all on the home page. However, we are just doing this as an exercise in setting up a master/detail pattern.

Create the Detail Component

Let’s start by creating our DetailComponent. Like our HomeComponent, this component is going to be routed to (not used inside of another components template). Because we are routing to this component, we will consider it as another “feature” in our application and it will get its own folder (and its own route in the main routing file).

import { Component } from '@angular/core';
@Component({
selector: 'app-detail',
template: ` <h2>Detail</h2> `,
})
export default class DetailComponent {}
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'home',
loadComponent: () => import('./home/home.component'),
},
{
path: 'detail/:id',
loadComponent: () => import('./detail/detail.component'),
},
{
path: '',
redirectTo: 'home',
pathMatch: 'full',
},
];

We are routing to our DetailComponent now, but notice that we have specified an :id as a parameter. If we navigate to detail/12 the router will navigate to DetailComponent and the id value of 12 will be available to it to use.

Add an id property to Todo

Our todos don’t actually have an id property yet, and it’s going to be a bit hard to navigate to them using their id when they do not have one. Let’s add that now.

export interface Todo {
id: string;
title: string;
description: string;
}
export type CreateTodo = Omit<Todo, 'id'>;

We’re getting a bit fancy with TypeScript now. We have been creating todos already under the assumption that all a todo has is a title and a string but now we have changed that by adding an id.

However, we don’t want the user to supply the id when creating the todo. We want that to do that automatically. That means that when a user is creating a todo we only want them to have to supply the title and description. But, when we are displaying the todos in the application we will need to also include the id.

To deal with this, now have two different types: Todo and CreateTodo. We are using the Omit utility type from TypeScript which allows us to use an existing type (in this case Todo) and then remove specific properties from it (in this case id) in order to create a new type.

Now the following structure data would satisfy a Todo:

{
id: '1',
title: 'hello',
description: 'world'
}

and the following structure would satisfy a CreateTodo:

{
title: 'hello',
description: 'world'
}

Modifying the Todo type like this is going to cause some errors — you will see these in your code editor and also in the terminal where you are running ng serve. Let’s deal with that.

todoSubmitted = output<CreateTodo>();

Our form no longer emits data that satisfies the Todo type, as it is only emitting a title and description value.

This change will cause more problems.

import { Injectable, signal } from '@angular/core';
import { CreateTodo, Todo } from '../interfaces/todo';
@Injectable({
providedIn: 'root',
})
export class TodoService {
// We only want this class to be able to
// update the signal (# makes it private)
#todos = signal<Todo[]>([]);
// This can be read publicly
todos = this.#todos.asReadonly();
addTodo(todo: CreateTodo) {
this.#todos.update((todos) => [...todos, todo]);
}
}

This will still cause problems because now when we are trying to update our signal we are trying to add something of type CreateTodo to an array of elements of the type Todo. There is one more thing we need to do.

import { Injectable, signal } from '@angular/core';
import { CreateTodo, Todo } from '../interfaces/todo';
@Injectable({
providedIn: 'root',
})
export class TodoService {
// We only want this class to be able to
// update the signal (# makes it private)
#todos = signal<Todo[]>([]);
// This can be read publicly
todos = this.#todos.asReadonly();
addTodo(todo: CreateTodo) {
this.#todos.update((todos) => [
...todos,
{ ...todo, id: Date.now().toString() },
]);
}
}

Now instead of just adding the todo to the array directly, we create a new object using the existing title and description by spreading the todo object, and then we also add an id property onto it using the current time to serve as our unique value.

Now that we have a real id you can also update the track statement in the TodoListComponent if you want:

@for (todo of todos; track todo.id){

Now, when we navigate to our detail route, we can pass in the id for the specific todo we want to view as part of the route.

<a routerLink="/detail/{{ todo.id }}">{{ todo.title }}</a>

The routerLink won’t work by default here — see if you can figure out why and how to fix it before viewing the answer.

You will need to add the RouterLink import from @angular/router:

@Component({
selector: 'app-todo-list',
template: `
<ul>
@for (todo of todos(); track todo.id){
<li>
<a routerLink="/detail/{{ todo.id }}">{{ todo.title }}</a>
</li>
} @empty {
<li>Nothing to do!</li>
}
</ul>
`,
imports: [RouterLink],
})

Use the Route Param on the Detail Component

Once we navigate to the detail component, we need to take that id that we passed in as part of the route and grab all of the information for the todo that matches that id.

import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TodoService } from '../shared/data-access/todo.service';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-detail',
template: ` <h2>Detail</h2> `,
})
export default class DetailComponent {
private route = inject(ActivatedRoute);
private todoService = inject(TodoService);
private paramMap = toSignal(this.route.paramMap);
todo = computed(() =>
this.todoService
.todos()
.find((todo) => todo.id === this.paramMap()?.get('id'))
);
}

First, we need to get the id somehow. That is why we are injecting ActivatedRoute — this gives us information about the currently activated route. This information includes an observable stream called paramMap which contains a map of all the parameters in the URL (including our id).

Since we are primarily working with signals here, we convert this observable stream into a signal by using the toSignal method from @angular/core/rxjs-interop.

Now we can create a computed signal from the signal that contains all of our todos in the TodoService and our paramMap signal that contains the id. We use the find method on the array of todos to find the one specific todo we are interested in.

Now we can use that in that computed signal in the template.

@if (todo(); as todo){
<h2>{{ todo.title }}</h2>
<p>{{ todo.description }}</p>
} @else {
<p>Could not find todo...</p>
}

This is mostly pretty straight-forward: if there is a matching todo we display its information, otherwise we display a message saying we could not find it.

However, we are also creating an alias:

todo(); as todo

This serves two purposes for us. One is that it will allow us to just reference the todo value as todo within the if block rather than having to write todo() every time. The other purpose is that todo will have a narrowed type. Our todo() signal could actually either be a Todo or it could be undefined if there were no todo with a matching id. Since our if block is checking that it is defined, if we create an alias, TypeScript will know that todo is definitely defined within that if block.

Now we can view an individual todo on the detail page! It’s still ugly though, so in the next lesson we are going to make it a bit prettier.