Tutorial: Master/Detail Components
| REFERENCES |
|---|
| [1]: https://angular.io/tutorial/toh-pt4 "Services" |
The Tour of Heroes HeroesComponent is currently getting and displaying fake data.
After the refactoring in this tutorial, HeroesComponent will be lean and focused on supporting the view. It will also be easier to unit-test with a mock service.
Why services?
Components shouldn't fetch or save data directly and they certainly shouldn't knowingly present fake data. They should focus on presenting data and delegate data access to a service.
In this tutorial, you'll create a HeroService that all application classes can use to get heroes. Instead of creating that service with new, you'll rely on Angular dependency injection to inject it into the HeroesComponent constructor.
Services are a great way to share information among classes that don't know each other. You'll create a MessageService and inject it in two places:
- in
HeroServicewhich uses the service to send a message. - in
MessagesComponentwhich displays that message.
Create the HeroService
Using the Angular CLI, create a service called hero.
$ ng generate service hero
The command generates skeleton HeroService class in src/app/hero.service.ts The HeroService class should look like the below.
@Injectable() services
Notice that the new service imports the Angular Injectable symbol and annotates the class with the @Injectable() decorator.
The @Injectable() decorator tells Angular that this service might itself have injected dependencies. It doesn't have dependencies now but it will soon. Whether it does or it doesn't, it's good practice to keep the decorator.
The Angular style guidelines strongly recommend keeping it and the linter enforces this rule.
Get hero data
The HeroService could get hero data from anywhere—a web service, local storage, or a mock data source.
Removing data access from components means you can change your mind about the implementation anytime, without touching any components. They don't know how the service works.
The implementation in this tutorial will continue to deliver mock heroes.
Import the Hero and HEROES. And Add a getHeroes method to return the mock heroes.
src/app/hero.service.ts
import { Injectable } from '@angular/core';
import { Hero } from './hero';
import { HEROES } from './mock-heroes';
@Injectable()
export class HeroService {
constructor() { }
getHeroes(): Hero[] {
return HEROES;
}
}
Provide the HeroService
You must provide the HeroService in the dependency injection system before Angular can inject it into the HeroesComponent, as you will do below.
There are several ways to provide the HeroService: in the HeroesComponent, in the AppComponent, in the AppModule. Each option has pros and cons.
This tutorial chooses to provide it in the AppModule.
That's such a popular choice that you could have told the CLI to provide it there automatically by running this command when generating the new service ng generate service hero --module=app.module.
Open the AppModule class, import the HeroService, and add it to the @NgModule.providers array.
src/app/app.module.ts
...
import { HeroService } from './hero.service';
@NgModule({
...
providers: [HeroService, MessageService],
bootstrap: [AppComponent]
})
export class AppModule { }
The providers array tells Angular to create a single, shared instance of HeroService and inject into any class that asks for it.
The HeroService is now ready to plug into the HeroesComponent.
Update HeroesComponent
Open the HeroesComponent class file.
Delete the HEROES import as you won't need that anymore. Import the HeroService instead. Replace the the definition of the heroes property with a simple declaration.
src/app/heroes/heroes.component.ts
...
import { HeroService } from '../hero.service';
...
export class HeroesComponent implements OnInit {
heroes: Hero[];
...
}
Inject the HeroService
Add a private heroService parameter of type HeroService to the constructor.
src/app/heroes/heroes.component.ts
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
...
export class HeroesComponent implements OnInit {
heroes: Hero[];
selectedHero: Hero;
constructor(private heroService: HeroService) { }
...
}
The parameter simultaneously defines a private heroService property and identifies it as a HeroService injection site.
When Angular creates a HeroesComponent, the Dependency Injection system sets the heroService parameter to the singleton instance of HeroService.
Add getHeroes()
Create a function to retrieve the heroes from the service.
src/app/heroes/heroes.component.ts
...
import { HeroService } from '../hero.service';
...
export class HeroesComponent implements OnInit {
heroes: Hero[];
selectedHero: Hero;
constructor(private heroService: HeroService) { }
ngOnInit() { }
getHeroes(): void {
this.heroes = this.heroService.getHeroes();
}
...
}
Call it in ngOnInit
While you could call getHeroes() in the constructor, that's not the best practice.
Reserve the constructor for simple initialization such as wiring constructor parameters to properties. The constructor shouldn't do anything. It certainly shouldn't call a function that makes HTTP requests to a remote server as a real data service would.
Instead, call getHeroes() inside the ngOnInit lifecycle hook and let Angular call ngOnInit at an appropriate time after constructing a HeroesComponent instance.
src/app/heroes/heroes.component.ts
...
import { HeroService } from '../hero.service';
...
export class HeroesComponent implements OnInit {
heroes: Hero[];
selectedHero: Hero;
constructor(private heroService: HeroService) { }
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroes = this.heroService.getHeroes();
}
...
}
See it run
After the browser refreshes, the app should run as before, showing a list of heroes and a hero detail view when you click on a hero name.
Observable data
The HeroService.getHeroes() method has a synchronous signature, which implies that the HeroService can fetch heroes synchronously. The HeroesComponent consumes the getHeroes() result as if heroes could be fetched synchronously.
this.heroes = this.heroService.getHeroes();
This will not work in a real app. You're getting away with it now because the service currently returns mock heroes. But soon the app will fetch heroes from a remote server, which is an inherently asynchronous operation.
The HeroService must wait for the server to respond, getHeroes() cannot return immediately with hero data, and the browser will not block while the service waits.
HeroService.getHeroes() must have an asynchronous signature of some kind.
It can take a callback. It could return a Promise. It could return an Observable.
In this tutorial, HeroService.getHeroes() will return an Observable in part because it will eventually use the Angular HttpClient.get method to fetch the heroes and HttpClient.get() returns an Observable.
Observable HeroService
Observable is one of the key classes in the RxJS library.
In a later tutorial on HTTP, you'll learn that Angular's HttpClient methods return RxJS Observables. In this tutorial, you'll simulate getting data from the server with the RxJS of() function.
Open the HeroService file and import the Observable and of symbols from RxJS. Replace the getHeroes method.
src/app/hero.service.ts
import { Injectable } from '@angular/core';
import { Hero } from './hero';
import { HEROES } from './mock-heroes';
import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
@Injectable()
export class HeroService {
constructor() { }
getHeroes(): Observable<Hero[]> {
return of(HEROES);
}
}
of(HEROES) returns an Observable<Hero[]> that emits a single value, the array of mock heroes.
In the HTTP tutorial, you'll call
HttpClient.get<Hero[]>()which also returns anObservable<Hero[]>that emits a single value, an array of heroes from the body of the HTTP response.
Subscribe in HeroesComponent
The HeroService.getHeroes method used to return a Hero[]. Now it returns an Observable<Hero[]>.
You'll have to adjust to that difference in HeroesComponent.
Find the getHeroes method and replace it with the following code (shown side-by-side with the previous version for comparison)
src/app/heroes/heroes.component.ts
...
import { HeroService } from '../hero.service';
...
export class HeroesComponent implements OnInit {
heroes: Hero[];
selectedHero: Hero;
constructor(private heroService: HeroService) { }
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes);
}
...
}
Observable.subscribe() is the critical difference.
The previous version assigns an array of heroes to the component's heroes property. The assignment occurs synchronously, as if the server could return heroes instantly or the browser could freeze the UI while it waited for the server's response.
That won't work when the HeroService is actually making requests of a remote server.
The new version waits for the Observable to emit the array of heroes— which could happen now or several minutes from now. Then subscribe passes the emitted array to the callback, which sets the component's heroes property.
This asynchronous approach will work when the HeroService requests heroes from the server.
Show messages
In this section you will
- add a
MessagesComponentthat displays app messages at the bottom of the screen. - create an injectable, app-wide
MessageServicefor sending messages to be displayed - inject
MessageServiceinto theHeroService - display a message when
HeroServicefetches heroes successfully.
Create MessagesComponent
Use the CLI to create the MessagesComponent.
$ ng g component messages --module=app.module
The CLI creates the component files in the src/app/messages folder and declare MessagesComponent in AppModule.
Modify the AppComponent template to display the generated MessagesComponent.
/src/app/app.component.html
<app-mat-navbar></app-mat-navbar>
<div class="container-fluid">
...
<div class="row">
<div class="col-sm-4 offset-sm-4">
<app-messages></app-messages>
</div>
</div>
</div>
You should see the default paragraph from MessagesComponent at the bottom of the page.
Create the MessageService
Use the CLI to create the MessageService in src/app. The --module=app option tells the CLI to provide this service in the AppModule,
$ ng g service message --module=app.module
Open MessageService and replace its contents with the following.
/src/app/message.service.ts
import { Injectable } from '@angular/core';
@Injectable()
export class MessageService {
messages: string[] = [];
add(message: string): void {
this.messages.push(message);
}
clear(): void {
this.messages = [];
}
}
The service exposes its cache of messages and two methods: one to add() a message to the cache and another to clear() the cache.
Inject it into the HeroService
Re-open the HeroService and import the MessageService.
Modify the constructor with a parameter that declares a private messageService property. Angular will inject the singleton MessageService into that property when it creates the HeroService.
/src/app/hero.service.ts
...
import { MessageService } from './message.service';
@Injectable()
export class HeroService {
constructor(private messageService: MessageService) { }
...
}
This is a typical "service-in-service" scenario: you inject the
MessageServiceinto theHeroServicewhich is injected into theHeroesComponent.
Send a message from HeroService
Modify the getHeroes method to send a message when the heroes are fetched.
...
import { MessageService } from './message.service';
@Injectable()
export class HeroService {
constructor(private messageService: MessageService) { }
getHeroes(): Observable<Hero[]> {
// TODO: send the message _after_ fetching the heroes
this.messageService.add('HeroService: fetched heroes');
return of(HEROES);
}
}
Display the message from HeroService
The MessagesComponent should display all messages, including the message sent by the HeroService when it fetches heroes.
Open MessagesComponent and import the MessageService.
Modify the constructor with a parameter that declares a public messageService property. Angular will inject the singleton MessageService into that property when it creates the HeroService.
/src/app/messages/messages.component.ts
import { Component, OnInit } from '@angular/core';
import { MessageService } from '../message.service';
...
export class MessagesComponent implements OnInit {
constructor(public messageService: MessageService) { }
ngOnInit() { }
}
The messageService property must be public because you're about to bind to it in the template.
Angular only binds to public component properties.
Bind to the MessageService
Replace the CLI-generated MessagesComponent template with the following.
src/app/messages/messages.component.html
<div *ngIf="messageService.messages.length">
<h2>Messages</h2>
<button class="clear"
(click)="messageService.clear()">clear</button>
<div *ngFor='let message of messageService.messages'> {{message}} </div>
</div>
This template binds directly to the component's messageService.
- The
*ngIfonly displays the messages area if there are messages to show.
- An
*ngForpresents the list of messages in repeated<div>elements.
- An Angular event binding binds the button's click event to
MessageService.clear().
The browser refreshes and the page displays the list of heroes. Scroll to the bottom to see the message from the HeroService in the message area. Click the "clear" button and the message area disappears.
Summary
- You refactored data access to the
HeroServiceclass. - You provided the
HeroServicein the rootAppModuleso that it can be injected anywhere. - You used Angular Dependency Injection to inject it into a component.
- You gave the
HeroServiceget data method an asynchronous signature. - You discovered
Observableand the RxJS Observable library. - You used RxJS
of()to return an Observable of mock heroes (Observable<Hero[]>). - The component's
ngOnInitlifecycle hook calls theHeroServicemethod, not the constructor. - You created a
MessageServicefor loosely-coupled communication between classes. - The
HeroServiceinjected into a component is created with another injected service,MessageService.