普段はフロントエンドエンジニアとしてJavaScript/TypeScript/Aangular/GraphQLを
メインに開発業務を行なっています。
以前、Angularの状態管理ライブラリである@ngrx-component-storeを使用したカウンターアプリを作成しました。

今回はこの@ngrx-component-storeを使用して、APIからデータの取得・更新・削除ができるアプリを作成したいと思います。
このアプリはMediumに掲載されている記事のPart1〜Part2を参考にして作成しています。Part3はJestによるテストがメインなので、興味ある方は試してみるといいかもしれません。
@ngrx-component-storeの情報はかなり少ないので、このように公開されている記事は本当に貴重だと思います。感謝。
概要
このアプリでは@ngrx/component-storeを使用した状態管理を行うことができます。
また、selecter、updater、effectを使用して、データの取得・更新・削除を行います。
■ @ngrx/component-storeの公式ドキュメント
ソースコードは全てGithubに掲載しています。
最終的なアプリの外観
最終的な外観です。

APIからデータを取得して一覧表示させています。
右側のアイコンをクリックすると表を編集することが可能です。

バージョン情報
Angular:12.2.17
Angular Material: 12.2.13
NgRX/component-store:13.1.0
RxJS:6.6.7
ディレクトリーの構成
ディレクトリの構成は以下の通りです。
star-wars.web-service.tsでAPIからデータを取得して、person.store.tsでデータの状態管理を行なっていきます。component配下でView周りを組み込んでいきます。
├── app │ ├── app-routing.module.ts │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── component │ │ ├── person │ │ │ ├── person.component.html │ │ │ ├── person.component.scss │ │ │ └── person.component.ts │ │ ├── person-edit │ │ │ ├── person-edit.compoent.ts │ │ │ ├── person-edit.component.html │ │ │ └── person-edit.component.scss │ │ └── person-list │ │ ├── person-list.component.html │ │ ├── person-list.component.scss │ │ └── person-list.component.ts │ ├── models │ │ └── person-state.ts │ ├── service │ │ └── star-wars.web-service.ts │ ├── store │ │ └── person.store.ts │ └── voes │ ├── person.ts │ └── response.ts ├── assets ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.sass └── test.ts
APIからデータを取得する
Star WarsAPIは、映画スターウォーズに登場する人物の体重、身長、乗り物などのデータを取得することができるAPIです。
例えば、「 https://swapi.dev/api/people/1/ 」と入力すると、ルークスカイウォーカーの情報を取得することが出来ます。
{ "name": "Luke Skywalker", "height": "172", "mass": "77", "hair_color": "blond", "skin_color": "fair", "eye_color": "blue", "birth_year": "19BBY", "gender": "male", }
型定義
取得するデータの型です。
export interface Person { id: number; name: string; birth_year: string; eye_color: string; gender: string; hair_color: string; height: string; mass: string; skin_color: string; }
import { Person } from './person'; export interface Response { results: Person[]; }
serviceの作成
Star Wars APIからデータを取得します。
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Response } from '../voes/response'; import { Person } from '../voes/person'; @Injectable({ providedIn: 'root', }) export class StarsWarsWebService { constructor(private http: HttpClient) {} /** * Person情報をStarWarsAPIから取得する。 * @returns {Person[]} */ getPeople(): Observable<Person[]> { let id: number = 1; let persons: Person[] = []; return this.http.get<Response>(`${environment.API_ROOT}/people`).pipe( map((response) => { for (const person of response.results) { if (!person.id) { // id情報の追加 const PersonDate = { id: id, name: person.name, birth_year: person.birth_year, eye_color: person.eye_color, gender: person.gender, hair_color: person.hair_color, height: person.height, mass: person.mass, skin_color: person.skin_color, }; persons.push(PersonDate); } id++; } return persons; }), catchError(this.handleError<Person[]>(`getPerson`, [])) ); } /** * Person情報を保存する。 * @param {number} id * @param {Person} person * @returns {*} Person */ savePerson(person?: Person, id?: number): Observable<any> { return of(person); } }
HttpClientを使用して、StarWarsAPIからデータを取得します。また元データにはID情報が存在しない為、新たにID情報を追加してStoreに渡しています。
catchErrorオペレータでデータ取得に失敗した場合はログ出力を行うようにします。
/** * エラーハンドリング * @param {string} operation * @param {T} result */ private handleError<T>(operation = 'operation', result?: T) { return (error: any): Observable<T> => { console.error(error); return of(result as T); }; } }
Componen-storeの実装
まずは、Component-storeのライブラリをインストールします。
npm install @ngrx/component-store --save
storeの作成
状態を管理するStoreを作成します。
import { Injectable, OnDestroy } from '@angular/core'; import { Observable, Subject, Subscription } from 'rxjs'; import { switchMap, tap, withLatestFrom } from 'rxjs/operators'; import { ComponentStore } from '@ngrx/component-store'; import { StarsWarsWebService } from 'src/app/service/star-wars.web-service'; import { PersonState } from '../models/person-state'; import { Person } from 'src/app/voes/person'; @Injectable() export class PersonStore extends ComponentStore<PersonState> implements OnDestroy { private saveEditPerson$ = new Subject<void>(); private sub: Subscription = new Subscription(); constructor(private starsWarsWebService: StarsWarsWebService) { super({ people: [], editId: undefined, editedPerson: undefined, } ); ngOnDestroy() { this.sub.unsubscribe(); } // Personの値をStoreから取得 readonly people$: Observable<Person[]> = this.select(({ people }) => people); // ID情報をStoreから取得 readonly editId$: Observable<number | undefined> = this.select( ({ editId }) => editId ); // 編集済みPerson情報をStoreから取得、ログの表示 readonly editedPerson$: Observable<Person | undefined> = this.select( ({ editedPerson }) => editedPerson ).pipe( tap((Person) => { console.log('editedPerson:', Person); }) ); }
componentStoreでは@InjectableデコレータにprovideInを記載していません。他のサービスクラスやコンポーネントに依存性の注入が可能となり、データの独立性が損なわれてしまう為です。
constructorのsuperに、更新したいデータを格納し初期値を設定します。
Storeに格納する型の定義は以下の通りです。
import { Person } from 'src/app/voes/person'; export interface PersonState { people: Person[]; editId?: number; editedPerson?: Person; }
データを取得する際には、select()メゾットというselectorが提供されているため、そちらを使用します。
■ 公式ドキュメント
constructor(@Optional() @Inject(initialStateToken) defaultState?: T) { // State can be initialized either through constructor or setState. if (defaultState) { this.initState(defaultState); } } . . // 省略 . . /** * Creates a selector. * * This supports combining up to 4 selectors. More could be added as needed. * * @param projector A pure projection function that takes the current state and * returns some new slice/projection of that state. * @param config SelectConfig that changes the behavior of selector, including * the debouncing of the values until the state is settled. * @return An observable of the projector results. */ select<R>(projector: (s: T) => R, config?: SelectConfig): Observable<R>;
selecterではconstructorで渡された値が初期値となります。「projector A」には純粋関数が選択されます。純粋関数は副作用が発生せず、常に同じ返り値となります。
stateを更新する場合は、updater()を使用します。
// Personの値をアップデート readonly loadPeople = this.updater((state, people: Person[] | null) => ({ ...state, people: people || [], })); // ID情報をアップデート readonly setEditId = this.updater((state, editId: number | undefined) => ({ ...state, editId, })); // 編集するperson情報をアップデート readonly setEditedPerson = this.updater( (state, editedPerson: Person | undefined) => ({ ...state, editedPerson, }) );
非同期処理を行う場合、effectを使用します。
// 編集済みPerson情報とid情報を保存 const saveData$ = this.saveEditPerson$.pipe( withLatestFrom(this.editedPerson$, this.editId$), switchMap(([, person, editId]) => this.starsWarsWebService.savePerson(person, editId) ) ); // Person情報をアップデート this.sub.add( saveData$.subscribe({ next: (person) => { // Person情報をアップデート this.editPerson(person); // 編集済みのPerson情報を空に設定 this.clearEditedPerson(); }, error: (error) => { console.error(error); }, }) ); } ngOnDestroy() { this.sub.unsubscribe(); } // 既存のPerson情報のIDと一致すれば、Person情報の編集内容を保存 readonly editPerson = this.effect( (personId$: Observable<number | undefined>) => personId$.pipe( withLatestFrom(this.people$), tap<[number | undefined, Person[]]>(([id, people]) => { this.setEditId(id); const personToEdit: Person | undefined = !id && id !== 0 ? undefined : people.find((person) => person.id === id); this.setEditedPerson({ ...(personToEdit as any) }); }) ) );
effectを使用することで、コンポーネントから副作用を分離することができます。NgRXといった関数型プログラミングの場合、純粋関数、不変性を目指しており、サイドエフェクト(非同期処理などの副作用)は存在しない為、effectがその役割を担います。NgRXではActionが存在して受け取ったActionを元にHTTP通信や別のActionを実行するなどの処理を担いますが、@ngrx/component-storeの場合はActionが存在しないのが特徴です。
stateをクリアにする場合の処理です。
Stateにundefinedを渡すことでStoreの値をクリアにすることができます。
/** * 編集をキャンセル * @returns {*} */ public cancelEditPerson(): any { this.clearEditedPerson(); } /** * Person情報を保存 * @returns {*} */ public saveEditPerson(): any { this.saveEditPerson$.next(); } /** * 更新情報を空に設定 * @returns {*} */ private clearEditedPerson(): any { this.setEditId(undefined); this.setEditedPerson(undefined); }
今回は使用しませんが、空のオブジェクトを渡すことでもStoreの中身を空にすることができます。
/** * 全データをリセット */ private readonly clearEditId = this.updater((state) => { return {} as PersonState; });
Component &Viewの処理
Storeから現在のPerson情報を取得して、一覧表示を行います。
import { ChangeDetectionStrategy, Component, EventEmitter, OnDestroy, OnInit, } from '@angular/core'; import { PersonStore } from 'src/app/store/person.store'; @Component({ selector: 'component-store-person-list', templateUrl: './person-list.component.html', styleUrls: ['./person-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class personListComponent implements OnInit, OnDestroy { protected readonly onDestroy$ = new EventEmitter(); // Person情報を取得 people$ = this.personStore.people$; displayedColumns = [ 'name', 'birth_year', 'eye_color', 'gender', 'hair_color', 'height', 'mass', 'controls', ]; constructor(private personStore: PersonStore) {} ngOnInit(): void {} ngOnDestroy(): void { this.onDestroy$.emit(); } /** * 編集するのPerson情報を取得 * @param {id} */ editPerson(id: number): void { this.personStore.editPerson(id); return; } }
一覧表示にはAngularのMaterial Tableを使用します。
<div class="container mat-elevation-z8"> <table mat-table [dataSource]="people$"> <ng-container matColumnDef="name"> <th mat-header-cell *matHeaderCellDef> Name </th> <td mat-cell *matCellDef="let element"> {{element.name}} </td> </ng-container> <ng-container matColumnDef="birth_year"> <th mat-header-cell *matHeaderCellDef> Birth Year </th> <td mat-cell *matCellDef="let element"> {{element.birth_year}} </td> </ng-container> <ng-container matColumnDef="eye_color"> <th mat-header-cell *matHeaderCellDef> Eye Color </th> <td mat-cell *matCellDef="let element"> {{element.eye_color}} </td> </ng-container> <ng-container matColumnDef="gender"> <th mat-header-cell *matHeaderCellDef> Gender </th> <td mat-cell *matCellDef="let element"> {{element.gender}} </td> </ng-container> <ng-container matColumnDef="hair_color"> <th mat-header-cell *matHeaderCellDef> Hair Color </th> <td mat-cell *matCellDef="let element"> {{element.hair_color}} </td> </ng-container> <ng-container matColumnDef="height"> <th mat-header-cell *matHeaderCellDef> Height (cm) </th> <td mat-cell *matCellDef="let element"> {{element.height}} </td> </ng-container> <ng-container matColumnDef="mass"> <th mat-header-cell *matHeaderCellDef> Mass (kg) </th> <td mat-cell *matCellDef="let element"> {{element.mass}} </td> </ng-container> <ng-container matColumnDef="controls"> <th mat-header-cell *matHeaderCellDef></th> <td mat-cell *matCellDef="let element"> <button mat-icon-button color="primary" (click)="editPerson(element.id)"> <mat-icon>mode_edit</mat-icon> </button> </td> </ng-container> <tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> </table> </div>
person-edit.compoent.tsにデータの編集処理を行います。
import { Component, Input } from '@angular/core'; import { Person } from 'src/app/voes/person'; import { PersonStore } from 'src/app/store/person.store'; import { Observable } from 'rxjs'; @Component({ selector: 'component-store-edit-person', templateUrl: './person-edit.component.html', styleUrls: ['./person-edit.component.scss'], }) export class EditPersonComponent { @Input() person?: Person; constructor(private personStore: PersonStore) {} ngOnInit() {} // Id情報を取得 get editId$(): Observable<number | undefined> { return this.personStore.editId$; } /** * 変更分を確定。 * @returns {void} */ personEdited(): void { this.personStore.setEditedPerson(this.person); return; } /** * キャンセル * @returns {void} */ cancelPerson(): void { this.personStore.cancelEditPerson(); } /** * 保存 * @returns {void} */ savePerson(): void { this.personStore.saveEditPerson(); } }
編集用のViewの処理です。
<h1>Editing {{editId$ | async}}</h1> <div class="container mat-elevation-z8"> <mat-form-field appearance="legacy"> <mat-label class="mat-label">Name</mat-label> <input matInput placeholder="Name of person" [(ngModel)]="person!.name" (ngModelChange)="personEdited()"> </mat-form-field> <mat-form-field appearance="legacy"> <mat-label class="mat-label">Eye Color</mat-label> <input matInput placeholder="Color of eyes (if applicable)" [(ngModel)]="person!.eye_color" (ngModelChange)="personEdited()"> </mat-form-field> <mat-form-field appearance="legacy"> <mat-label class="mat-label">Gender</mat-label> <input matInput placeholder="How they identify" [(ngModel)]="person!.gender" (ngModelChange)="personEdited()"> </mat-form-field> </div> <button mat-icon-button color="primary" (click)="savePerson()"> <mat-icon>save</mat-icon> </button> <button mat-icon-button color="warn" (click)="cancelPerson()"> <mat-icon>cancel</mat-icon> </button>
これで、データの取得、編集、削除を行うことができます。
本日はここまでとなります。
最後までお読みいただきありがとうございました。