[Angular][NgRx] EffectとSelectorの落とし穴
やりたいこと
NgRxで管理している状態(postState)のプロパティ(status)を更新した後、Effect内でPostStateのstatusを評価し、副作用を伴うActionをdispatchします。この副作用は、バックエンドに最新のPostの状態を渡すことを想定しています。
export interface Post {
id: number,
title: string | number;
comment: string | number;
status: string;
successMessage: string;
}
Postを追加した後、changeStatusActionでstatusを更新します。statusが完了(done)になった場合、changeStatusEffectでpostStateのupdateActionをdispatchし、updateEffectでバックエンドに最新のPostを渡す処理を行います。
下記のように実装しましたが、意図しないActionが何度もdispatchされる事象が起きました。
changeStatus$ = createEffect(() =>
this.actions$.pipe(
ofType(postActions.changeStatus),
tap(action => console.log(action)),
mergeMap(action => {
const {id, status} = action;
return this.store.select(postSelectors.post(id)).pipe(
filter((post) => post.status === 'done'),
map((post)=> {
return postActions.update({post});
})
)
})
)
)
update$ = createEffect(() =>
this.actions$.pipe(
ofType(postActions.update),
switchMap(({ post }) => {
return this.postService.update(post).pipe(
map(result => postActions.updateSuccess(result)),
catchError((error) => of(postActions.updateFailure({result: post, error})))
)
})
)
)
import { createAction, props } from '@ngrx/store';
export const changeComment = createAction(
'[Post Component] Change Comment',
props<{ id: number, comment: string | number}>()
);
export const changeStatus = createAction(
'[Post Component] Change Status',
props<{ id: number, status: string}>()
);
export const update = createAction(
'[Post Component] Update',
props<{post: any}>()
);
export const updateSuccess = createAction(
'[Post Component] Update Success',
props<{id: number, successMessage: string}>()
);
export const updateFailure = createAction(
'[Post Component] Update Failure',
props<{result: any, error: any}>()
);
import { createReducer, on } from '@ngrx/store';
import * as postActions from '../actions/post.actions';
import { initialState } from '../states/post.state';
import { adapter } from '../states/post.state';
import { Post } from '../models/post.model';
export const postReducer = createReducer(
initialState,
on(postActions.changeStatus, (state, { id, status}) => {
const changes = { status };
const update = { id, changes };
return adapter.updateOne(update, state);
}),
on(postActions.changeComment, (state, { id, comment}) => {
const changes = { comment };
const update = { id, changes };
return adapter.updateOne(update, state);
}),
on(postActions.update, (state, { post }) => {
return state
}),
on(postActions.updateSuccess, (state, { id, successMessage }) => {
console.log('update success')
const changes = { successMessage };
const update = { id, changes };
return adapter.updateOne(update, state);
}),
on(postActions.updateFailure, (state, { result, error }) => {
console.error('update failure', error)
return state
}),
);
<form [formGroup]="form">
<div>
<p>入力</p>
<p>タイトル:<input type="text" formControlName="title"></p>
<p *ngIf="errorMessageTitle !=='' ">{{errorMessageTitle}}</p>
<p>コメント:<input type="text" formControlName="comment"></p>
<p *ngIf="errorMessageComment !== '' ">{{errorMessageComment}}</p>
<button (click)="addPost()">投稿</button>
</div>
<div>
<p>Post一覧</p>
<ng-container *ngFor="let post of posts$ | async; index as i">
<div [formArrayName]="'post_' + post.id" style="border: 1px solid #ddd;">
<span>タイトル: {{post.title}}</span>
<div>
<span>コメント: </span>
<input formControlName="0" type="text">
<button (click)="changeComment(post.id)">変更</button>
</div>
<div>
<span>ステータス: </span>
<select formControlName="1" (change)="onSelectChangeStatus($event, post.id)">
<option value="doing">実施中</option>
<option value="done">完了</option>
</select>
{{post.successMessage}}
<span *ngIf="post.successMessage">{{post.successMessage}}</span>
</div>
</div>
</ng-container>
</div>
</form>
問題点
この実装では予期しないタイミングでaddCommentEffectが実行されてしまいます。changeStatusActionをdispatchしたときだけchangeStatusEffectが実行される想定ですが、postStateのcommentを変更するchangeCommentActionをdispatchした後にも実行されてしまいます。パフォーマンス上好ましくない状態です。
原因は、EffectでSelectorを用いてPostを取得している部分です。changeStatusAction以外のActionによってpostStateを更新すると、EffectのSelectorも最新の状態を取得します。すると、後続の条件を満たした場合、無駄にupdateActionがdispatchされていました。
これは、SelectorでStoreの最新の状態を取得していると、対象のStateが更新されると最新のObservableを取得し続けるという仕組みによるものです。
EffectでSelectorを使う場合はconcatLatestFromを使おう
💡 通常Effect内では selector
を直接使用するよりも、Actionから必要なデータを取得する方が好ましいです。これは、EffectがActionに対して反応し、Actionのデータを処理するために設計されているためです。
Effect内でSelectorを使用すると次のような問題が発生する可能性があります。
- Effectが同じ状態に対して複数実行される可能性があります。SelectorはStoreの最新状態を監視し続けるため、状態が変更されるたびにEffectが再評価されます。
- コンポーネントとの結合が強くなる。Effectはアクションに反応するために設計されており、コンポーネントとの疎結合性を保つことが重要です。
SelectorをEffect内で使用すると、コンポーネントの状態の詳細に依存してしまい、再利用性やテストの容易さが低下します。
したがって、一般的にはEffect内ではSelectorの代わりにActionから必要なデータを取得することが推奨されています。
concatLatestFrom
しかし、必要なメタデータがStateからしかアクセスできない場合もあります。Stateが必要な場合、RxJS withLatestFrom
または@ngrx/effects concatLatestFrom
オペレータを使用して提供することができます。
**concatLatestFrom
**オペレーターは、ストリームを結合するために使用されます。具体的には、2つのストリームを結合し、最新の値を使用して新しいストリームを作成します。一方のストリームが値を発行するたびに、もう一方のストリームの最新の値を取得し、それらを組み合わせて新しいストリームを作成します。
例)
import { concatLatestFrom } from '@ngrx/effects';
import * fromCustomers from '../customers';
this.actions$.pipe(
concatLatestFrom((action) => this.store.select(fromCustomers.selectCustomer(action.customerId)))
)
concatLatestFromで修正
今回はconcatLatestFrom
を使って正しいActionがdispatchされるまでSelectorが発火されないように修正します。
// concatLatestFrom
// EffectでSelectする
changeStatus$ = createEffect(() =>
this.actions$.pipe(
ofType(postActions.changeStatus),
**concatLatestFrom((action) => this.store.select(postSelectors.post(action.id)))**,
switchMap(([action, post]) => {
if(action.status === 'done') {
return of(postActions.update({post, status: action.status}));
} else {
return EMPTY;
}
})
)
);
export const changeStatus = createAction(
'[Post Component] Change Status',
props<{ **id: number, status: string**}>()
);
on(postActions.changeStatus, (state, { **id, status** }) => {
const changes = { **status** };
const update = { **id**, **changes** };
return adapter.updateOne(update, state);
}),
onSelectChangeStatus(e: Event, id: number) {
const formArray = this.form.get('post_'+ id) as FormArray;
const statusForm = formArray.at(1) as FormControl;
const status = statusForm.value;
if(status) {
// this.store.dispatch(postActions.changeStatus({ post, status }));
**this.store.dispatch(postActions.changeStatus({ id, status }));**
}
}
<form [formGroup]="form">
<div>
<p>入力</p>
<p>タイトル:<input type="text" formControlName="title"></p>
<p *ngIf="errorMessageTitle !=='' ">{{errorMessageTitle}}</p>
<p>コメント:<input type="text" formControlName="comment"></p>
<p *ngIf="errorMessageComment !== '' ">{{errorMessageComment}}</p>
<button (click)="addPost()">投稿</button>
</div>
<div>
<p>Post一覧</p>
<ng-container *ngFor="let post of posts$ | async; index as i">
<div [formArrayName]="'post_' + post.id" style="border: 1px solid #ddd;">
<span>タイトル: {{post.title}}</span>
<div>
<span>コメント: </span>
<input formControlName="0" type="text">
<button (click)="changeComment(post.id)">変更</button>
</div>
<div>
<span>ステータス: </span>
<select formControlName="1" **(change)="onSelectChangeStatus($event, post.id)">**
<option value="doing">実施中</option>
<option value="done">完了</option>
</select>
{{post.successMessage}}
<span *ngIf="post.successMessage">{{post.successMessage}}</span>
</div>
</div>
</ng-container>
</div>
</form>
EffectでSelectorを使っても、concatLatestFrom
により、changeCommentActionをdispatchしても不用意にupdateActionがdispatchされないようになりました。
直接Actionから取得することもできる
concatLatestFrom
を使わなくてもコンポーネントでSelectした状態をAction経由で取得することもできます。
下記はEffect内でSelectorでPostを取得するのではなく、Actionから必要なデータを取得するようにしました。
changeStatus$ = createEffect(() =>
this.actions$.pipe(
ofType(postActions.changeStatus),
**switchMap(({ post, status }) => {**
if(status === 'done') {
return of(postActions.update({post, status}))
} else {
return EMPTY
}
})
)
)
export const changeStatus = createAction(
'[Post Component] Change Status',
props<{ post: Post, status: string }>()
);
on(postActions.changeStatus, (state, { post, status }) => {
const changes = { status };
const update = { id: post.id, changes };
return adapter.updateOne(update, state);
}),
onSelectChangeStatus(e: Event, post: Post) {
const formArray = this.form.get('post_'+ post.id) as FormArray;
const statusForm = formArray.at(1) as FormControl;
const status = statusForm.value;
if(status) {
this.store.dispatch(postActions.changeStatus({ post, status }));
}
}
<form [formGroup]="form">
<div>
<p>入力</p>
<p>タイトル:<input type="text" formControlName="title"></p>
<p *ngIf="errorMessageTitle !=='' ">{{errorMessageTitle}}</p>
<p>コメント:<input type="text" formControlName="comment"></p>
<p *ngIf="errorMessageComment !== '' ">{{errorMessageComment}}</p>
<button (click)="addPost()">投稿</button>
</div>
<div>
<p>Post一覧</p>
<ng-container *ngFor="let post of posts$ | async; index as i">
<div [formArrayName]="'post_' + post.id" style="border: 1px solid #ddd;">
<span>タイトル: {{post.title}}</span>
<div>
<span>コメント: </span>
<input formControlName="0" type="text">
<button (click)="changeComment(post.id)">変更</button>
</div>
<div>
<span>ステータス: </span>
**<select formControlName="1" (change)="onSelectChangeStatus($event, post)">**
<option value="doing">実施中</option>
<option value="done">完了</option>
</select>
{{post.successMessage}}
<span *ngIf="post.successMessage">{{post.successMessage}}</span>
</div>
</div>
</ng-container>
</div>
</form>
今回のように、コンポーネントでSelectしている状態をEffect内で使いたい場合はconcatLatestFrom
を使わずに直接Actionのパラメータから取得しても良いのかなとも思いましたが、オペレーターの勉強を兼ねてconcatLatestFrom
も使ってみました。
Effect内だけで使いたい状態であれば積極的にconcatLatestFrom
を使っていきましょう。
参考
Discussion