Angular

【Angular】@ngrx/component-storeによる状態管理アプリ

マナビト
マナビト
こんにちはマナビトです。
普段はフロントエンドエンジニアとしてJavaScript/TypeScript/Aangular/GraphQLを
メインに開発業務を行なっています。

以前、Angularの状態管理ライブラリである@ngrx-component-storeを使用したカウンターアプリを作成しました。

【Angular】@ngrx/component-storeの使い方 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>

これで、データの取得、編集、削除を行うことができます。

本日はここまでとなります。
最後までお読みいただきありがとうございました。