😎

NgRxのイベントプラグインについて

に公開

NgRx@v19.2

NgRx@v19.2のアップデートで下記のプラグインが追加されました。
https://github.com/ngrx/platform/commit/980cf6f96d36cb0224f5bc6e3968d5bd8aef73f9

これは NgRx SignalStore にイベントベースの状態管理機能となります。
Flux アーキテクチャから影響を受けて開発したようで、NgRx Store,NgRx Effects,RxJSのベストプラクティスを取り入れてるようです。
ただ、これらの機能はまだ実験的機能(experimental)として提供されてるみたいです。

今回はこれらの機能を試していきたいと思います。
データや API はdummyjsonを使わせてもらいます。

使用方法

アクション定義

アクション定義にはeventGroupを使います。

export const todosPageEvents = eventGroup({
  source: "Todo Events", // イベント名の定義
  events: {
    // イベントを定義
    created: type<void>(),
    updated: type<void>(),
  },
});

状態変更

イベントが発火したタイミングで状態を変化する処理を実行するには、withReducer,onを使います。
on関数には、1 つ以上のイベントアクションを定義し、最後の引数に、配列を返す関数を定義します。
この配列には、複数の関数を定義することができます。
また,イベントで定義した値の Type を、({payload}) => []といった形で受け取れます。
このpayloadですが、2 つ以上のイベントアクションを定義した場合、イベントアクションで定義した値の Type のユニオン型になります。

todos.store.ts
import { signalStore, withState } from "@ngrx/signals";
import { Todo } from "../type/todo.type";
import { on, withReducer } from "@ngrx/signals/events";
import { todosPageEvents } from "./todos.action";

type Todos = {
  isLoading: boolean;
  data: Todo[];
};

export const TodosStore = signalStore(
  { providedIn: "root" },
  withState<Todos>({
    isLoading: false,
    data: [],
  }),
  // here
+  withReducer(
+    on(todosPageEvents.created, todosPageEvents.updated, () => [
+      function setIsLoading() {
+        return {
+          isLoading: true,
+        };
+      },
+    ])
+  )
);

複数関数の場合は下記のように書くと良さそうです。

todos.store.ts
+ function setLoading(isLoading:boolean): Pick<Todos,'isLoading'>{
+    return {
+        isLoading
+    }
+}

+ function setTodos(todos:Todo[]): Pick<Todos,'data'>{
+    return {
+        data: todos
+    }
+}

export const TodosStore = signalStore(
  { providedIn: 'root' },
  withState<Todos>({
    isLoading: false,
    data: [],
  }),
+  withReducer(
+    on(/** ... */, () => [
+      setLoading(false),
+      setTodos(/** ... */),
+    ])
+  )
);

副作用の定義

副作用の定義はwithEffects関数を使います。
各副作用はEventsサービスを使用し、イベントが発火されたタイミングで実行されるObservableを定義します。

todos.store.ts
export const TodosStore = signalStore(
  { providedIn: 'root' },
  withState<Todos>(/** ... */),
  withReducer(/** ... */),
  // here
+  withEffects((_store, events = inject(Events), todoAPI = inject(TodoAPI)) => ({
+    loadTodos$: events.on(todosPageEvents.loaded).pipe(
+      exhaustMap(() =>
+        todoAPI.getTodos().pipe(
+          mapResponse({
+            next: ({ todos }) => todosApiEvents.loadedSuccess(todos),
+            error: () => todosApiEvents.loadedFailure(),
+          })
+        )
+      )
+    ),
+  }))
);

Component での利用方法

コンポーネント側では、今まで同様に Store をコンポーネントに注入する形で利用できます。

app.component.ts
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
import { TodosStore } from "./todos/todos.store";

@Component({
  selector: "app-root",
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
  private readonly todosStore = inject(TodosStore);
}

また、イベントアクションの発行を行う方法は 2 通りあります。

Dispatcherを利用する方法

この方法では、Dispatcherサービスを注入し、それを用いてアクションを発行する形になります。
利点は、複数のイベントアクションを発行できます。

app.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TodosStore } from './todos/todos.store';
import { Dispatcher } from '@ngrx/signals/events';
import { todosPageEvents } from './todos/todos.action';

@Component({
  selector: 'app-root',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
  private readonly dispatcher = inject(Dispatcher);

  private readonly todosStore = inject(TodosStore);

  getTodos(): void {
+    this.dispatcher.dispatch(todosPageEvents.loaded());
  }
}

injectDispatchを利用する方法

この方法では,injectDispatchでイベントアクションを注入できます。
利点は、注入したイベントアクション以外利用できないのでコードの可読性が上がります。

app.component.ts
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
import { TodosStore } from "./todos/todos.store";
import { Dispatcher } from "@ngrx/signals/events";
import { todosPageEvents } from "./todos/todos.action";

@Component({
  selector: "app-root",
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
  private readonly dispatcher = inject(Dispatcher);

  private readonly todosPageEventsDispatch = injectDispatch(todosPageEvents);

  getTodos(): void {
+    this.todosPageEventsDispatch.loaded();
  }
}

signalStoreFeatureとの親和性

signalStore は単一ファイルに書けますが、signalStoreFeature関数と組み合わせるとかなりすっきりしたコードが書くことができます。

下記コードでは、withReducerwithEffectsを同じファイルに書いています。

with-todos-reducer.ts
import { signalStore, withState } from '@ngrx/signals';
import { Todo } from '../type/todo.type';
import {
  Dispatcher,
  Events,
  on,
  withEffects,
  withReducer,
} from '@ngrx/signals/events';
import { todosApiEvents, todosPageEvents } from './todos.action';
import { inject } from '@angular/core';
import { TodoAPI } from './todo.api';
import { exhaustMap, map } from 'rxjs';
import { mapResponse } from '@ngrx/operators';

type Todos = {
  isLoading: boolean;
  data: Todo[];
};

export const TodosStore = signalStore(
  { providedIn: 'root' },
  withState<Todos>({
    isLoading: false,
    data: [],
  }),
  withReducer(
    on(todosPageEvents.loaded, todosPageEvents.updated, () => [
      setLoading(true),
    ]),
    on(todosApiEvents.loadedSuccess, ({ payload }) => [
      setLoading(false),
      setTodos(payload),
    ])
  ),
  withEffects(
    (
      store,
      events = inject(Events),
      todoAPI = inject(TodoAPI),
      dispatcher = inject(Dispatcher)
    ) => ({
      loadTodos$: events.on(todosPageEvents.loaded).pipe(
        exhaustMap(() =>
          todoAPI.getTodos().pipe(
            mapResponse({
              next: ({ todos }) => todosApiEvents.loadedSuccess(todos),
              error: () => todosApiEvents.loadedFailure(),
            })
          )
        )
      ),
      updateTodo$: events.on(todosPageEvents.updated).pipe(
        map(({ payload }) => {
          const { id, todo } = payload;

          const todos = store.data().map((value) => {
            if (value.id === id) {
              return {
                ...value,
                ...todo,
              };
            }

            return value;
          });

          return todosApiEvents.loadedSuccess(todos);
        })
      ),
    })
  )
);

function setLoading(isLoading: boolean): Pick<Todos, 'isLoading'> {
  return {
    isLoading,
  };
}

function setTodos(todos: Todo[]): Pick<Todos, 'data'> {
  return {
    data: todos,
  };
}

ただこれらをsignalStoreFeature関数を利用することで、reducer部分やeffects部分で切り分けることができます。

withReducerの部分

with-todos-effects.ts
import { signalStoreFeature, type } from '@ngrx/signals';
import { on, withReducer } from '@ngrx/signals/events';
import { todosApiEvents, todosPageEvents } from './todos.action';
import { Todo, Todos } from '../type/todo.type';

export function withTodosReducer() {
  return signalStoreFeature(
    { state: type<Todos>() },
    withReducer(
      on(todosPageEvents.loaded, todosPageEvents.updated, () => [
        setLoading(true),
      ]),
      on(todosApiEvents.loadedSuccess, ({ payload }) => [
        setLoading(false),
        setTodos(payload),
      ])
    )
  );
}

function setLoading(isLoading: boolean): Pick<Todos, 'isLoading'> {
  return {
    isLoading,
  };
}

function setTodos(todos: Todo[]): Pick<Todos, 'data'> {
  return {
    data: todos,
  };
}

withEffectsの部分

import { inject } from "@angular/core";
import { signalStoreFeature, type } from "@ngrx/signals";
import { Events, withEffects } from "@ngrx/signals/events";
import { TodoAPI } from "./todo.api";
import { todosApiEvents, todosPageEvents } from "./todos.action";
import { exhaustMap, map } from "rxjs";
import { mapResponse } from "@ngrx/operators";
import { Todos } from "../type/todo.type";

export function withTodosEffects() {
  return signalStoreFeature(
    { state: type<Todos>() },
    withEffects(
      (store, events = inject(Events), todoAPI = inject(TodoAPI)) => ({
        loadTodos$: events.on(todosPageEvents.loaded).pipe(
          exhaustMap(() =>
            todoAPI.getTodos().pipe(
              mapResponse({
                next: ({ todos }) => todosApiEvents.loadedSuccess(todos),
                error: () => todosApiEvents.loadedFailure(),
              })
            )
          )
        ),
        updateTodo$: events.on(todosPageEvents.updated).pipe(
          map(({ payload }) => {
            const { id, todo } = payload;

            const todos = store.data().map((value) => {
              if (value.id === id) {
                return {
                  ...value,
                  ...todo,
                };
              }

              return value;
            });

            return todosApiEvents.loadedSuccess(todos);
          })
        ),
      })
    )
  );
}

上記のように書くことで store 本体がかなりすっきりします。

todos.store.ts
import { signalStore, withState } from '@ngrx/signals';
import { Todos } from '../type/todo.type';
import { withTodosReducer } from './with-todos-reducer';
import { withTodosEffects } from './with-todos-effects';

const state: Todos = {
  isLoading: false,
  data: [],
};

export const TodosStore = signalStore(
  { providedIn: 'root' },
  withState<Todos>(state),
  withTodosReducer(),
  withTodosEffects()
);

最後に

signalStoreでは flux 思考でやるには別途パッケージを入れる必要がありましたが、今回のアップデートで flux 思考に近い形で書けるようなったと思います。
ここで作成したものは github の方にあげておきます。

https://github.com/mzkmnk/ngrx-with-events-projects

GitHubで編集を提案

Discussion