🐟

[Angular][NgRx] EffectとSelectorの落とし穴

2023/06/21に公開

やりたいこと

NgRxで管理している状態(postState)のプロパティ(status)を更新した後、Effect内でPostStateのstatusを評価し、副作用を伴うActionをdispatchします。この副作用は、バックエンドに最新のPostの状態を渡すことを想定しています。

PostState
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される事象が起きました。

PostEffect
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})))
      )
    })
  )
)
PostAction
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}>()
);
PostReducer
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
  }),
);
PostHtml
<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を使用すると次のような問題が発生する可能性があります。

  1. Effectが同じ状態に対して複数実行される可能性があります。SelectorはStoreの最新状態を監視し続けるため、状態が変更されるたびにEffectが再評価されます。
  2. コンポーネントとの結合が強くなる。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が発火されないように修正します。

PostEffect
// 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;
      }
    })
  )
);
PostAction
export const changeStatus = createAction(
  '[Post Component] Change Status',
  props<{ **id: number, status: string**}>()
);
PostReducer
on(postActions.changeStatus, (state, { **id, status** }) => {
  const changes = { **status** };
  const update = { **id**, **changes** };
  return adapter.updateOne(update, state);
}),
PostComponent
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 }));**
  }
}
PostHtml
<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から必要なデータを取得するようにしました。

PostEffect
changeStatus$ = createEffect(() =>
  this.actions$.pipe(
    ofType(postActions.changeStatus),
    **switchMap(({ post, status }) => {**
      if(status === 'done') {
        return of(postActions.update({post, status}))
      } else {
        return EMPTY
      }
    })
  )
)
PostAction
export const changeStatus = createAction(
  '[Post Component] Change Status',
  props<{ post: Post, status: string }>()
);
PostReducer
on(postActions.changeStatus, (state, { post, status }) => {
  const changes = { status };
  const update = { id: post.id, changes };
  return adapter.updateOne(update, state);
}),
PostComponent
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 }));
  }
}
PostHtml
<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を使っていきましょう。

参考

https://ngrx.io/guide/effects
https://ngrx.io/api/effects/concatLatestFrom

Discussion