この記事の目的
の2つの記事を踏まえて、AngularのアプリケーションでAkitaを導入し、単一のStateに依存するComponentを作成する流れのメモ。
流れに沿って実装すれば以下画像のデータフローの状態になるが、各ブロックに細かい説明は書いていない。

実装を直接見たい場合は Sandbox-Frontend/Angular-Architecture-Sample at master · beatdjam/Sandbox-Frontend · GitHub を参照。
環境構築
ブログ用サンプル 環境構築 · beatdjam/Sandbox-Frontend@015f59e · GitHub
Angular CLIインストール
$ npm install -g @angular/cli
Akita CLIインストール
$ npm install @datorama/akita-cli -g
プロジェクトの初期設定
プロジェクト作成
$ ng new <name>
Akita導入
$ npm install @datorama/akita
iniのバージョンが古くて怒られるけどコードジェネレータでの利用のみということで特に対応されていないっぽい
ini 1.3.5 - dependency vulnerability in npm audit · Issue #698 · datorama/akita · GitHub
package.jsonに下記を追記するとAkita CLIで作成されるファイルがAngularプロジェクト用になる(TypeScript、Angularのアノテーションがつくなど)
"akitaCli": {
"customFolderName": "true",
"template": "angular",
"basePath": "./src/app"
},
$ npm i @datorama/akita-ngdevtools --save-dev
Redux DevTools - Chrome ウェブストア でStoreの状態を確認できるようにする
app.module.tsのimportsに AkitaNgDevtools.forRoot()
の記述を追加する
imports: [
environment.production ? [] : AkitaNgDevtools.forRoot(),
BrowserModule,
BrowserAnimationsModule
],
実装
ユーザー一覧を表示する
ブログ用サンプル 画面表示実装 · beatdjam/Sandbox-Frontend@8c77299 · GitHub
CLIでcomponent作成
$ ng g component
app.component.htmlから作成したcomponentを呼び出す
<app-user-list></app-user-list>
app.module.tsのimportsに HttpClientModuleを追加
imports: [
environment.production ? [] : AkitaNgDevtools.forRoot(),
BrowserModule,
AppRoutingModule,
HttpClientModule,
]
レスポンスを扱うModelを作成
export interface User {
id: string;
first_name: string;
last_name: string;
email: string;
avatar: string;
}
ユーザー一覧APIのリクエスト部分を作成
$ ng g service
Reqres - A hosted REST-API ready to respond to your AJAX requests を使ってAPI取得部分を作成
import { Injectable } from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {User} from "../model/user";
import {map, Observable} from "rxjs";
const apiHost = 'https://reqres.in/api';
interface ApiResponse<T> {
data: T;
}
@Injectable({
providedIn: 'root'
})
export class UsersService {
constructor(private http: HttpClient) {
}
get users$(): Observable<User[]> {
return this.http
.get<ApiResponse<User[]>>(`${apiHost}/users`)
.pipe(map(resp => resp.data))
}
}
ComponentからAPIを呼び出す部分を作成
import { Component, OnInit } from '@angular/core';
import {UsersService} from "../../repository/users.service";
@Component({
selector: 'app-user-list',
templateUrl: './user-list.component.html',
styleUrls: ['./user-list.component.scss']
})
export class UserListComponent implements OnInit {
users$ = this.service.users$;
constructor(private service: UsersService) { }
ngOnInit(): void {
}
}
取得したデータを画面に表示するテンプレートを作成
<ul>
<li *ngFor="let user of users$ | async">
#{{user.id}} {{user.first_name}} {{user.last_name}}
</li>
</ul>
処理の分離と単方向データフローの適用
ブログ用サンプル Store経由の単方向データフローにする · beatdjam/Sandbox-Frontend@122880a · GitHub
Store, Queryの作成
$ akita
Akita CLIで作る場合のオプションに出てくるHttp Entity Serviceは今回利用していない。
import{Injectable}from '@angular/core';
import{EntityState, EntityStore, StoreConfig}from '@datorama/akita';
export class UserListItem{
constructor(
public id: string,
public first_name: string,
public last_name: string
) {}
}
export interface UserListState extends EntityState<UserListItem> {}
@Injectable({providedIn: 'root'})
@StoreConfig({
name: 'UserList',
idKey: 'id'
})
export class UserListStore extends EntityStore<UserListState> {
constructor() {
super();
}
}
import{Injectable}from '@angular/core';
import{QueryEntity}from '@datorama/akita';
import{UserListStore, UserListState}from './user-list.store';
@Injectable({providedIn: 'root'})
export class UserListQuery extends QueryEntity<UserListState> {
constructor(protected override store: UserListStore) {
super(store);
}
}
Usecase作成
$ ng g service
Serviceと同じように使うのでserviceで作成する
import { Injectable } from '@angular/core';
import {UsersService} from "../../repository/users.service";
import {UserListItem, UserListStore} from "./user-list.store";
import {tap} from "rxjs";
@Injectable({
providedIn: 'root'
})
export class UserListUsecaseService {
constructor(private service: UsersService, private store: UserListStore) { }
fetchUsers() {
this.store.setLoading(true);
this.service.getAllUsers().pipe(
tap(res => {
const users = res.map(user => {
return new UserListItem(user.id, user.first_name,user.last_name);
});
this.store.add(users);
})
).subscribe(() => this.store.setLoading(false));
}
}
ComponentからQueryの購読
import { Component, OnInit } from '@angular/core';
import {UserListUsecaseService} from "./user-list-usecase.service";
import {UserListQuery} from "./user-list.query";
@Component({
selector: 'app-user-list',
templateUrl: './user-list.component.html',
styleUrls: ['./user-list.component.scss']
})
export class UserListComponent implements OnInit {
users$ = this.query.selectAll();
constructor(private usecase: UserListUsecaseService, private query: UserListQuery) { }
ngOnInit(): void {
this.usecase.fetchUsers();
}
}
Single State Streamパターンを適用する
ブログ用サンプル Single State Streamパターン適用 · beatdjam/Sandbox-Frontend@c9739c4 · GitHub
Single State Streamの説明は下記を参照
第2章 Effective RxJS - コンポーネントにおけるObservableの購読
要約すると、同じObservableにAsyncパイプを適用したり、いくつものObservableを同期的に処理する必要があるとき、それらを単一のStreamに合成して扱うことでテンプレートで扱う状態を単純化できるというもの。
Stateを合成する
import { Component, OnInit } from '@angular/core';
import {UserListUsecaseService} from "./user-list-usecase.service";
import {UserListQuery} from "./user-list.query";
import {map, Observable} from "rxjs";
import {combineQueries} from "@datorama/akita";
import {UserListItem} from "./user-list.store";
@Component({
selector: 'app-user-list',
templateUrl: './user-list.component.html',
styleUrls: ['./user-list.component.scss']
})
export class UserListComponent implements OnInit {
readonly state$: Observable<UserStateViewModel>;
constructor(private usecase: UserListUsecaseService, private query: UserListQuery) {
this.state$ = combineQueries([this.query.selectAll()]).pipe(
map(([users]) => new UserStateViewModel(users))
);
}
ngOnInit(): void {
this.usecase.fetchUsers();
}
}
class UserStateViewModel {
constructor(public users: UserListItem[]) {
}
}
合成したStateをテンプレートから利用する
<ng-container *ngIf="state$ | async as state">
<ul>
<li *ngFor="let user of state.users">
#{{user.id}} {{user.first_name}} {{user.last_name}}
</li>
</ul>
</ng-container>