NgRxのイベントプラグインについて
NgRx@v19.2
NgRx@v19.2のアップデートで下記のプラグインが追加されました。
これは 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 のユニオン型になります。
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,
+ };
+ },
+ ])
+ )
);
複数関数の場合は下記のように書くと良さそうです。
+ 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
を定義します。
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 をコンポーネントに注入する形で利用できます。
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
サービスを注入し、それを用いてアクションを発行する形になります。
利点は、複数のイベントアクションを発行できます。
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
でイベントアクションを注入できます。
利点は、注入したイベントアクション以外利用できないのでコードの可読性が上がります。
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
関数と組み合わせるとかなりすっきりしたコードが書くことができます。
下記コードでは、withReducer
やwithEffects
を同じファイルに書いています。
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
の部分
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 本体がかなりすっきりします。
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 の方にあげておきます。
Discussion