NgRx に触れてみる 1
はじめに
前から気になっていた NgRx に触れてみます。
NgRX は Redux のように状態を管理するためのものです。
今回は Redux のチュートリアルにある tic-tac-toe をなんとなく真似してみることにします。
環境
- Angular: 10.0.2
- @ngrx/store: 9.2.0
- @ngrx/effects: 9.2.0 (Effects で使用)
Install
npx ng add @ngrx/store
元のプロジェクト
まずは NgRx を使用しないプロジェクトを用意しますよ( Effects に関連するところは後で)。
app.component.html
<app-board></app-board>
board.component.html
<div class="board">
<app-square
*ngFor="let s of state.squares; let i = index"
[props]="s" (onClick)="updateSquare(i)"></app-square>
</div>
board.component.ts
import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';
@Component({
selector: 'app-board',
templateUrl: './board.component.html',
styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
public state: BoardState;
constructor() {
this.state = this.initialState();
}
ngOnInit(): void {
}
public updateSquare(index: number) {
const squares = this.state.squares.slice();
squares[index] = (this.state.nextIsX)? '✕': '◯';
this.state = {
nextIsX: ! this.state.nextIsX,
squares
};
}
private initialState(): BoardState {
return {
nextIsX: true,
squares: Array(9).fill(null),
};
}
}
board.component.css
.board {
display: flex;
flex-direction: row;
flex-wrap: wrap;
height: auto;
width: 21vw;
}
board-state.ts
import { SquareValue } from './square/square-value';
export type BoardState = {
nextIsX: boolean,
squares: Array<SquareValue>
};
square.component.html
<button class="square" (click)="click()">{{props}}</button>
square.component.ts
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { SquareValue } from './square-value';
@Component({
selector: 'app-square',
templateUrl: './square.component.html',
styleUrls: ['./square.component.css']
})
export class SquareComponent implements OnInit {
@Input() props: SquareValue;
@Output() onClick: EventEmitter<void> = new EventEmitter();
constructor() { }
ngOnInit(): void {
}
public click() {
this.onClick.emit();
}
}
square.component.css
.square{
height: 7vw;
width: 7vw;
}
実行すると 9 個のボタンが表示され、クリックすると順に「◯」「✕」が表示されます。
Action, Reducer, Store を追加する
Action を作る
「何が起こったか」(今回はボタンがクリックされた) を表現する Action を作ります。
game.actions.ts
import { createAction, props } from '@ngrx/store';
export const updateSquare = createAction('[Game] updateSquare',
props<{index: number}>());
Action を作る上で必須になるのは第一引数である「type」のみです。
ですが、今回はクリックされたボタンの配列番号が欲しかっったので「props」を第二引数として追加しています。
Reducer を作る
次は「Action によって状態がどう変わるか」(今回は「◯」または「✕」が入る)を表現する Reducer を追加します。
game.reducer.ts
import { createReducer, on, Action } from "@ngrx/store";
import { BoardState } from './board/board-state';
import { updateSquare } from './game.actions';
function initialState(): BoardState {
return {
nextIsX: true,
squares: Array(9).fill(null),
};
}
function getUpdatedState(lastState: BoardState, index: number): BoardState {
const squares = lastState.squares.slice();
squares[index] = (lastState.nextIsX)? '✕': '◯';
return {
nextIsX: ! lastState.nextIsX,
squares
};
}
const _gameReducer = createReducer(initialState(),
on(updateSquare, (state, {index}) => getUpdatedState(state, index)));
export function reducer(state: BoardState | undefined, action: Action) {
return _gameReducer(state, action);
}
今回は Action を一つだけ登録していますが、二つ以上の場合は下記のように追加していきます。
const _gameReducer = createReducer(initialState(),
on(updateSquare, (state, {index}) => getUpdatedState(state, index)),
on(resetSqure, (state) => initialState()));
StoreModule に Reducer を登録する
Reducer を有効にし、状態管理が行われるようにするため StoreModule に登録します。
app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { StoreModule } from '@ngrx/store';
import { BoardComponent } from './tic-tac-toe/board/board.component';
import { SquareComponent } from './tic-tac-toe/board/square/square.component';
import * as gameReducer from './tic-tac-toe/game.reducer';
@NgModule({
declarations: [
AppComponent,
BoardComponent,
SquareComponent
],
imports: [
BrowserModule,
StoreModule.forRoot({game: gameReducer.reducer})
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Component に接続する
「Board」 Component で今の状態の取得、 Action 、 Reducer を使って状態の更新をできるようにします。
board.component.ts
import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { updateSquare } from '../game.actions';
@Component({
selector: 'app-board',
templateUrl: './board.component.html',
styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
// receive current state
public state$: Observable<BoardState>;
constructor(private store: Store<{game: BoardState}>) {
this.state$ = store.pipe(select('game'));
}
ngOnInit(): void {
}
public updateSquare(index: number) {
// call the reducer to update the state
this.store.dispatch(updateSquare({index}));
}
}
ここで重要だと思うのは「select('game')」です。
引数の「game」は app.module.ts で登録したものと合わせる必要があります。
ここが違っているとエラーは発生しませんが状態を受け取ることができない、という問題が発生します(ちょっとハマりました)。
board.component.html
<div class="board">
<app-square
*ngFor="let s of (state$|async)?.squares; let i = index"
[props]="s" (onClick)="updateSquare(i)"></app-square>
</div>
現状
ここまでで下記のような流れができています。
Effects を使う
次は Effects(@ngrx/effects) を使ってみます。
名前からすると「side-effects (副作用)」なのかな?と思いきや、ネットワークリクエストなど、外部リソースにアクセスする場合に使われるようです。
Reducer のように、「store.dispatch」を使って Action を発行することで処理を呼び出します。
流れとしてはこんな感じ。。。のはず。
Install
npx ng add @ngrx/effects
元のプロジェクトに Service を追加する
今回のサンプルでは、ボタンがクリックされ、状態が変化したタイミングでゲームの勝敗がついたかを確認する処理を追加します。
簡易化のためサーバーにアクセスなどはしてません。
check-game-finished.ts
import { SquareValue } from './board/square/square-value';
import { GameResult } from './game-result';
type CheckTarget = {
index: number,
match: readonly CheckTarget[],
unmatch: readonly CheckTarget[]
};
const emptyTarget = {
match: [],
unmatch: []
};
const checkTargets: CheckTarget = {
index: 0,
match: [{
index: 1,
match: [{
index:2,
...emptyTarget
}],
unmatch: []
}, {
index: 3,
match: [{
index: 6,
...emptyTarget
}],
unmatch: []
}, {
index: 4,
match: [{
index: 8,
...emptyTarget
}],
unmatch: []
}],
unmatch: [{
index: 4,
match: [{
index: 1,
match: [{
index: 7,
...emptyTarget
}, {
index: 3,
match: [{
index: 5,
...emptyTarget
}],
unmatch: []
}],
unmatch: []
}],
unmatch:[{
index: 8,
match: [{
index: 2,
...emptyTarget
},{
index: 6,
...emptyTarget
}],
unmatch: []
}]
}]
};
function getTargetIndices(squares: readonly SquareValue[]): { circles: number[], crosses: number[]} {
const circles = new Array<number>();
const crosses = new Array<number>();
for(let i = 0; i < squares.length; i++) {
if (squares[i] == '◯') {
circles.push(i);
} else if(squares[i] == '✕') {
crosses.push(i);
}
}
return { circles, crosses };
}
function check(targetIndices: readonly number[],
nextTarget: CheckTarget): boolean {
if (targetIndices.some(i => nextTarget.index == i)) {
if (nextTarget.match.length <= 0) {
return true;
}
if (nextTarget.match.some(t => check(targetIndices, t))) {
return true;
}
} else {
if (nextTarget.unmatch.length <= 0) {
return false;
}
if (nextTarget.unmatch.some(t => check(targetIndices, t))) {
return true;
}
}
return false;
}
export function checkGameFinished(squares: readonly SquareValue[]): GameResult {
console.log("check");
const targets = getTargetIndices(squares);
if (check(targets.circles, checkTargets)) {
return { finished: true, winner: '◯' };
} else if (check(targets.crosses, checkTargets)) {
return { finished: true, winner: '✕' };
}
return { finished: false, winner: null };
}
ゲームの勝敗確認の処理はもう少し簡略化したい。。。
game-score.service.ts
import { Injectable } from '@angular/core';
import { SquareValue } from './board/square/square-value';
import { Observable, of } from 'rxjs';
import { GameResult } from './game-result';
import * as GameChecker from './check-game-finished';
@Injectable({
providedIn: 'root'
})
export class GameScoreService {
constructor() { }
public checkResult(squares: readonly SquareValue[]): Observable<GameResult> {
return of(GameChecker.checkGameFinished(squares));
}
}
Effects を追加する
game-result.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { GameScoreService } from './game-score.service';
import { checkScore, gameFinished } from './game.actions';
import { EMPTY } from 'rxjs';
@Injectable()
export class GameResultEffects {
/* this would be fired by dispatching "checkScore" action. */
checkResult$ = createEffect(() => this.actions$.pipe(
ofType(checkScore),
/* call service */
mergeMap(action => this.gameScoreService.checkResult(action.squares)
.pipe(
/* make an action fired */
map(result => gameFinished({ result })),
catchError(error => {
console.error(error);
return EMPTY;
})
))));
constructor(
private actions$: Actions,
private gameScoreService: GameScoreService
) {}
}
game.action.ts
import { createAction, props } from '@ngrx/store';
import { SquareValue } from './board/square/square-value';
import { GameResult } from './game-result';
...
export const checkScore = createAction('[Score] checkScore',
props<{ squares: readonly SquareValue[]}>());
export const gameFinished = createAction('[Game] finished',
props<{ result: GameResult }>());
Effects を登録する
Reducer と同じように app.module.ts に Effects を追加します。
app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { StoreModule } from '@ngrx/store';
import { BoardComponent } from './tic-tac-toe/board/board.component';
import { SquareComponent } from './tic-tac-toe/board/square/square.component';
import * as gameReducer from './tic-tac-toe/game.reducer';
import { EffectsModule } from '@ngrx/effects';
import { GameResultEffects } from './tic-tac-toe/game-result.effects';
@NgModule({
declarations: [
AppComponent,
BoardComponent,
SquareComponent
],
imports: [
BrowserModule,
StoreModule.forRoot({game: gameReducer.reducer}),
EffectsModule.forRoot([GameResultEffects])
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Component に接続する(失敗)
こちらも Reducer のときと同じように Component に状態受け取りと Action 発行の処理を追加します。
board.component.ts
import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { updateSquare, checkScore } from '../game.actions';
import { GameResult } from '../game-result';
import { SquareValue } from './square/square-value';
@Component({
selector: 'app-board',
templateUrl: './board.component.html',
styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
public state$: Observable<BoardState>;
public result$: Observable<GameResult>;
constructor(private store: Store<{game: BoardState, result: GameResult }>) {
this.state$ = store.pipe(select('game'));
this.state$.subscribe(s => this.onValueChanged(s.squares));
this.result$ = store.select(state => state.result);
this.result$.subscribe(r => console.log(r));
}
ngOnInit(): void {
}
public updateSquare(index: number) {
this.store.dispatch(updateSquare({index}));
}
private onValueChanged(squares: readonly SquareValue[]) {
this.store.dispatch(checkScore({squares}));
}
}
課題と対策
「gameScoreService.checkResult」の処理実行などは問題ないにもかかわらず、「this.result$.subscribe(r => console.log(r));」が実行されません。
結局 Effects から発行されている「gameFinished」の Action をハンドリングする Reducer がいない、ということが原因でした。
Effects から Action を発行すると、良い感じに StoreModule が状態をさばいてくれる、とかいう都合の良い展開にはならない、ということですね。
ということで Reducer を追加します。
game-result.reducer.ts
import { GameResult } from './game-result';
import { createReducer, on, Action } from '@ngrx/store';
import { gameFinished } from './game.actions';
function initialState(): GameResult {
return {
finished: false,
winner: null
};
}
const _resultReducer = createReducer(initialState(),
on(gameFinished, (state, {result}) => {
if (result.finished) {
return result;
}
return state;
}));
export function reducer(state: GameResult | undefined, action: Action) {
return _resultReducer(state, action);
}
app.module.ts
...
import * as gameResultReducer from './tic-tac-toe/game-result.reducer';
import { EffectsModule } from '@ngrx/effects';
import { GameResultEffects } from './tic-tac-toe/game-result.effects';
@NgModule({
...
imports: [
BrowserModule,
StoreModule.forRoot({game: gameReducer.reducer, result: gameResultReducer.reducer}),
EffectsModule.forRoot([GameResultEffects])
],
...
これで result$ の変化も受け取ることができました。
今回は何もしていませんが、 Service で例外が発生した場合 Effects から Action として発行することができるため、実運用ではこちらも Reducer で処理することになると思います。
次回に続く。。。はず