- Angular
- TypeScript
- 技術 💻
NgRx SignalStore の使い方|v22でTODOアプリを全コード解説
NgRx SignalStore とは、Angular の signal を土台にした状態管理ライブラリ(@ngrx/signals)で、状態・派生値・操作・非同期処理を signalStore() 1つにまとめて書ける仕組みです。 withState で状態、withComputed で派生値、withMethods で操作を足していく「積み木」のような書き方で、Action や Reducer を用意しなくても使えます。
この記事では、Angular 22.2.0 と @ngrx/signals 22.0.1 で TODO アプリ(読み込み・追加・完了・フィルタ)を全コード付き で作ります。サーバーを立てずに試せるモックAPI付きなので、そのまま貼って動かせます。主要 API の早見表、「サービス+signal/SignalStore/従来の NgRx Store」の選び方、Vitest でのストアのテスト結果、Playwright(Chromium)での画面確認の結果、よくある型エラーの実際の文言までまとめました。
NgRx SignalStore の API 早見表
| API | 読み込み元 | 役割 | TODO アプリでの使い方 |
|---|---|---|---|
signalStore() |
@ngrx/signals |
ストアを定義する(クラスが返る) | { providedIn: 'root' } でアプリ全体に1つ |
withState() |
@ngrx/signals |
状態を定義。各プロパティが signal になる | filter・loading・error |
withComputed() |
@ngrx/signals |
派生値(computed)を足す |
表示する TODO、残り件数 |
withMethods() |
@ngrx/signals |
操作(メソッド)を足す | フィルタ変更、読み込み、追加、完了 |
patchState() |
@ngrx/signals |
状態を部分的に更新する | patchState(store, { filter }) |
withProps() |
@ngrx/signals |
任意のプロパティ(依存など)を足す | _api: inject(TodoApi) |
withHooks() |
@ngrx/signals |
作成時・破棄時の処理 | onInit で一覧を読み込む |
rxMethod() |
@ngrx/signals/rxjs-interop |
RxJS の演算子で非同期処理を書く | API 呼び出しと結果の反映 |
withEntities() |
@ngrx/signals/entities |
ID 付きの一覧(ids・entityMap・entities)を持つ |
TODO の一覧 |
setAllEntities / addEntity / updateEntity |
@ngrx/signals/entities |
一覧を更新する関数 | 読み込み・追加・完了の反映 |
tapResponse() |
@ngrx/operators |
成功・失敗を分けて処理する演算子 | エラー時のメッセージ設定 |
unprotected() |
@ngrx/signals/testing |
テストで外から patchState できるようにする |
テストで状態を直接設定 |
このほか 22.0.1 には signalState・signalMethod・withLinkedState・withFeature・signalStoreFeature・getState・watchState・deepComputed と、イベント方式の @ngrx/signals/events があります(パッケージの型定義で確認)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「NgRx SignalStore の使い方|v22でTODOアプリを全コード解説」
サービス+signal/SignalStore/従来の NgRx Store の選び方
「状態の量」と「チームで揃えたい書き方の強さ」で選びます。 どれも正しい選択肢で、同じアプリの中で使い分けることもできます。
| 観点 | サービス+signal |
NgRx SignalStore | 従来の NgRx Store(@ngrx/store) |
|---|---|---|---|
| 追加パッケージ | 不要 | @ngrx/signals |
@ngrx/store、@ngrx/effects など |
| 書く量 | 最小 | 少なめ | 多め(Action・Reducer・Selector・Effect) |
| 状態の読み方 | signal | signal | Selector(selectSignal で signal にも) |
| 非同期処理 | 自分で subscribe など |
rxMethod(RxJS)や async メソッド |
Effects |
| 一覧の管理 | 自分で配列操作 | withEntities で ID 付き一覧 |
@ngrx/entity |
| 外からの書き換え防止 | asReadonly() を自分で |
既定で保護(patchState は内部だけ) |
Action 経由のみ |
| 向いている規模 | 画面1〜数枚の状態 | 画面単位〜機能単位の状態 | 大きなアプリ全体、イベントの記録を重視 |
迷ったら「サービス+signal」で始め、状態や操作が増えて1つのサービスが長くなってきたら SignalStore に移す と、段階的に整理できます。
作る TODO アプリと全体の流れ
画面は1枚で、入力欄・フィルタボタン・一覧・残り件数を表示します。 データはモックAPI(HTTP インターセプター)から取得します。
flowchart LR
P[TodoPage<br>画面] -- "store.add() / toggle() / setFilter()" --> S[TodoStore<br>signalStore]
S -- "visibleTodos() / remaining()" --> P
S -- rxMethod --> A[TodoApi<br>HttpClient]
A --> M[mockTodoBackend<br>インターセプター]
M -- 300ms後に応答 --> A
検証環境
| 項目 | バージョン |
|---|---|
| Angular CLI / Angular | 22.2.0 |
| @ngrx/signals / @ngrx/operators | 22.0.1 / 22.0.1 |
| Node.js | 24.21.0 |
| Vitest | 5.0.2 |
| Playwright(@playwright/test) | 1.63.0 |
npx -y -p @angular/cli@22 ng new todo-store --skip-git --defaults --style=css --ssr=false --routing --ai-config=none
cd todo-store
npm i @ngrx/signals@22 @ngrx/operators@22
@ngrx/signals 22 の peerDependencies は @angular/core ^22.0.0 と rxjs ^6.5.3 || ^7.4.0 です。 Angular のメジャーバージョンと NgRx のメジャーバージョンを揃えて入れます。
全コード(コピペで動く7ファイル)
1. 型(todo.model.ts)
// src/app/todo/todo.model.ts
export interface Todo {
id: number;
title: string;
done: boolean;
}
export type TodoFilter = 'all' | 'active' | 'done';
2. モックAPI(mock-todo-backend.ts)
/api/todos への GET・POST・PATCH をブラウザ内で処理し、300ms 遅らせて返します。
// src/app/todo/mock-todo-backend.ts
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { Todo } from './todo.model';
let todos: Todo[] = [
{ id: 1, title: '備品リストを更新する', done: false },
{ id: 2, title: '会議室を予約する', done: true },
];
let nextId = 3;
/** /api/todos をブラウザ内で返すモックAPI(学習用) */
export const mockTodoBackend: HttpInterceptorFn = (req, next) => {
if (!req.url.startsWith('/api/todos')) return next(req);
const id = Number(req.url.split('/')[3]);
let body: unknown = null;
if (req.method === 'GET') {
body = todos;
} else if (req.method === 'POST') {
const todo: Todo = { id: nextId++, title: (req.body as { title: string }).title, done: false };
todos = [...todos, todo];
body = todo;
} else if (req.method === 'PATCH') {
todos = todos.map((t) => (t.id === id ? { ...t, ...(req.body as Partial<Todo>) } : t));
body = todos.find((t) => t.id === id);
}
return of(new HttpResponse({ status: 200, body })).pipe(delay(300));
};
3. API サービス(todo-api.ts)
// src/app/todo/todo-api.ts
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Todo } from './todo.model';
@Injectable({ providedIn: 'root' })
export class TodoApi {
private readonly http = inject(HttpClient);
list() {
return this.http.get<Todo[]>('/api/todos');
}
add(title: string) {
return this.http.post<Todo>('/api/todos', { title });
}
update(id: number, changes: Partial<Todo>) {
return this.http.patch<Todo>(`/api/todos/${id}`, changes);
}
}
4. ストア本体(todo.store.ts)
ここが SignalStore の中心です。 with... を上から順に重ね、後の機能は前の機能で作った状態やメソッドを使えます。
// src/app/todo/todo.store.ts
import { computed, inject } from '@angular/core';
import {
patchState,
signalStore,
withComputed,
withHooks,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { addEntity, setAllEntities, updateEntity, withEntities } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { tapResponse } from '@ngrx/operators';
import { concatMap, filter, map, pipe, switchMap, tap } from 'rxjs';
import { TodoApi } from './todo-api';
import { Todo, TodoFilter } from './todo.model';
type TodoState = {
filter: TodoFilter;
loading: boolean;
error: string | null;
};
const initialState: TodoState = { filter: 'all', loading: false, error: null };
export const TodoStore = signalStore(
{ providedIn: 'root' },
// 1) 状態:Todo の一覧(ids / entityMap / entities)と画面の状態
withEntities<Todo>(),
withState(initialState),
// 2) 依存:_ で始まるメンバーはストアの外から見えない
withProps(() => ({ _api: inject(TodoApi) })),
// 3) 派生値
withComputed(({ entities, filter }) => ({
visibleTodos: computed(() => {
const f = filter();
return entities().filter((t) => (f === 'all' ? true : f === 'done' ? t.done : !t.done));
}),
remaining: computed(() => entities().filter((t) => !t.done).length),
})),
// 4) 操作
withMethods((store) => ({
setFilter(filter: TodoFilter): void {
patchState(store, { filter });
},
load: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() =>
store._api.list().pipe(
tapResponse({
next: (todos) => patchState(store, setAllEntities(todos), { loading: false }),
error: () => patchState(store, { loading: false, error: '読み込みに失敗しました' }),
}),
),
),
),
),
add: rxMethod<string>(
pipe(
map((title) => title.trim()),
filter((title) => title.length > 0),
concatMap((title) =>
store._api.add(title).pipe(
tapResponse({
next: (todo) => patchState(store, addEntity(todo)),
error: () => patchState(store, { error: '追加に失敗しました' }),
}),
),
),
),
),
toggle: rxMethod<Todo>(
pipe(
// 先に画面を更新(楽観的更新)し、失敗したら元に戻す
tap((todo) => patchState(store, updateEntity({ id: todo.id, changes: { done: !todo.done } }))),
concatMap((todo) =>
store._api.update(todo.id, { done: !todo.done }).pipe(
tapResponse({
next: () => {},
error: () =>
patchState(store, updateEntity({ id: todo.id, changes: { done: todo.done } }), {
error: '更新に失敗しました',
}),
}),
),
),
),
),
})),
// 5) ライフサイクル:ストアが作られたら読み込む
withHooks({
onInit(store) {
store.load();
},
}),
);
5. 画面(todo-page.ts)
コンポーネントは inject(TodoStore) で受け取り、テンプレートでは signal を読むだけです。
// src/app/todo/todo-page.ts
import { Component, inject, signal } from '@angular/core';
import { TodoStore } from './todo.store';
import { TodoFilter } from './todo.model';
@Component({
selector: 'app-todo-page',
template: `
<h1>TODO</h1>
<form (submit)="$event.preventDefault(); store.add(title()); title.set('')">
<input id="title" placeholder="やることを入力" [value]="title()" (input)="title.set($any($event.target).value)" />
<button id="add" type="submit">追加</button>
</form>
<nav>
@for (f of filters; track f.value) {
<button type="button" [attr.data-filter]="f.value" [class.active]="store.filter() === f.value"
(click)="store.setFilter(f.value)">{{ f.label }}</button>
}
</nav>
@if (store.loading()) { <p id="loading">読み込み中…</p> }
@if (store.error(); as error) { <p id="error">{{ error }}</p> }
<ul id="list">
@for (todo of store.visibleTodos(); track todo.id) {
<li>
<label>
<input type="checkbox" [checked]="todo.done" (change)="store.toggle(todo)" />
<span [class.done]="todo.done">{{ todo.title }}</span>
</label>
</li>
}
</ul>
<p id="remaining">残り {{ store.remaining() }} 件</p>
`,
styles: `.done { text-decoration: line-through; } .active { font-weight: bold; }`,
})
export class TodoPage {
protected readonly store = inject(TodoStore);
protected readonly title = signal('');
protected readonly filters: { value: TodoFilter; label: string }[] = [
{ value: 'all', label: 'すべて' },
{ value: 'active', label: '未完了' },
{ value: 'done', label: '完了' },
];
}
6. ルートコンポーネント(app.ts)
// src/app/app.ts(生成された app.html と app.css は削除)
import { Component } from '@angular/core';
import { TodoPage } from './todo/todo-page';
@Component({
selector: 'app-root',
imports: [TodoPage],
template: `<app-todo-page />`,
})
export class App {}
7. 設定(app.config.ts)
// src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { mockTodoBackend } from './todo/mock-todo-backend';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([mockTodoBackend])),
],
};
npx ng serve で http://localhost:4200 を開くと動きます(ng build も成功を確認済み)。
各 API の役割をコードで確認する
withState と patchState:状態を持って部分的に更新する
withState に渡したオブジェクトの各プロパティは、ストアの signal(store.filter() など)になります。 更新は patchState(store, { filter }) のように、変えたいプロパティだけを渡します。patchState にはエンティティ更新関数(setAllEntities(...) など)と部分オブジェクトを並べて渡せる ので、「一覧を入れ替えて loading を戻す」を1回で書けます。
withEntities:ID 付きの一覧を持つ
withEntities<Todo>() を書くと、ids・entityMap・entities の3つがストアに加わります。 entities() は配列、entityMap()[1] は ID で1件を取り出す形です。
| 関数 | 動き |
|---|---|
setAllEntities(list) |
一覧をまるごと入れ替える |
addEntity(item) / addEntities(list) |
追加する |
updateEntity({ id, changes }) |
1件の一部を更新する |
updateEntities({ ids, changes }) / updateAllEntities(changes) |
複数・全件を更新する |
removeEntity(id) / removeEntities(ids) / removeAllEntities() |
削除する |
ID のプロパティ名が id でない場合は、entityConfig で ID の取り出し方を指定し、withEntities と更新関数の両方に渡します(検証済み)。
import { signalStore, type } from '@ngrx/signals';
import { addEntity, entityConfig, withEntities } from '@ngrx/signals/entities';
const itemConfig = entityConfig({
entity: type<{ code: string; name: string }>(),
selectId: (item) => item.code,
});
export const ItemStore = signalStore(withEntities(itemConfig));
// 更新するとき:patchState(store, addEntity({ code: 'A-1', name: 'ペン' }, itemConfig));
withComputed:派生値を作る
withComputed は、それまでに定義した状態を引数で受け取り、computed を返します。 フィルタや件数のように「状態から計算できる値」は、状態として持たずにここで作ると、更新漏れが起きません。
withProps:依存やヘルパーを足す
withProps(() => ({ _api: inject(TodoApi) })) のように、ストアに任意のプロパティを追加できます。 名前が _ で始まるメンバーはストアの外から見えないため、依存しているサービスを隠しておけます。
rxMethod:RxJS で非同期処理を書く
rxMethod<入力の型>(pipe(...)) で、入力を受け取る Observable の処理をそのままメソッドにできます。 演算子の選び方で、連続して呼ばれたときの動きを決められます。
| 演算子 | 連続して呼ばれたとき | TODO アプリでの使いどころ |
|---|---|---|
switchMap |
前の処理を取り消して最新だけ | 一覧の読み込み |
concatMap |
順番に1つずつ | 追加・完了(順番を守る) |
mergeMap |
並行して全部 | 互いに関係しない保存 |
exhaustMap |
処理中は次を無視 | 二重送信を防ぎたいボタン |
rxMethod には値だけでなく、signal や Observable も渡せます(型定義 Input | (() => Input) | Observable<Input>)。signal を渡すと、値が変わるたびに処理が動きます。
withHooks:作成時に読み込む
withHooks({ onInit(store) { store.load(); } }) で、ストアが作られたときに一覧を読み込みます。 providedIn: 'root' のストアは、最初に inject されたとき(ここでは TodoPage の表示時)に作られます。破棄時の処理は onDestroy に書けます。
Vitest でストアをテストする
ストアは TestBed.inject(TodoStore) で取り出し、API はモックに差し替えます。 rxMethod の中の処理は、of() のような同期の Observable を返すモックなら、呼び出し直後に結果を確認できます。
// src/app/todo/todo.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { patchState } from '@ngrx/signals';
import { unprotected } from '@ngrx/signals/testing';
import { of, throwError } from 'rxjs';
import { TodoApi } from './todo-api';
import { TodoStore } from './todo.store';
import { Todo } from './todo.model';
const seed: Todo[] = [
{ id: 1, title: '備品リストを更新する', done: false },
{ id: 2, title: '会議室を予約する', done: true },
];
function setup(api: Partial<Record<keyof TodoApi, unknown>>) {
TestBed.configureTestingModule({
providers: [{ provide: TodoApi, useValue: { list: vi.fn(() => of(seed)), ...api } }],
});
return TestBed.inject(TodoStore);
}
describe('TodoStore', () => {
it('onInit で一覧を読み込む', () => {
const store = setup({});
expect(store.entities()).toEqual(seed);
expect(store.loading()).toBe(false);
expect(store.remaining()).toBe(1);
});
it('フィルタで表示が切り替わる', () => {
const store = setup({});
store.setFilter('done');
expect(store.visibleTodos().map((t) => t.id)).toEqual([2]);
store.setFilter('active');
expect(store.visibleTodos().map((t) => t.id)).toEqual([1]);
});
it('空白だけのタイトルは追加しない', () => {
const add = vi.fn((title: string) => of({ id: 3, title, done: false }));
const store = setup({ add });
store.add(' ');
store.add(' 名刺を発注する ');
expect(add).toHaveBeenCalledTimes(1);
expect(add).toHaveBeenCalledWith('名刺を発注する');
expect(store.ids()).toEqual([1, 2, 3]);
});
it('更新に失敗したら完了状態を元に戻す', () => {
const update = vi.fn(() => throwError(() => new Error('500')));
const store = setup({ update });
store.toggle(store.entityMap()[1]);
expect(store.entityMap()[1].done).toBe(false);
expect(store.error()).toBe('更新に失敗しました');
});
it('読み込みエラーをメッセージにする', () => {
const store = setup({ list: vi.fn(() => throwError(() => new Error('down'))) });
expect(store.error()).toBe('読み込みに失敗しました');
expect(store.entities()).toEqual([]);
});
it('unprotected でテスト用に状態を直接書き換える', () => {
const store = setup({});
patchState(unprotected(store), { filter: 'done' });
expect(store.visibleTodos()).toHaveLength(1);
});
});
画面とストアをまとめて確かめるテスト(todo-page.spec.ts)では、provideHttpClient() と provideHttpClientTesting() を登録し、HttpTestingController で GET と POST に応答を返して「読み込み → 追加 → 残り 2 件」の表示を確認しました。
npx ng test --watch=false --reporters=verbose
RUN v5.0.2 /…/todo-store
✓ |todo-store| src/app/todo/todo.store.spec.ts > TodoStore > onInit で一覧を読み込む 23ms
✓ |todo-store| src/app/todo/todo.store.spec.ts > TodoStore > フィルタで表示が切り替わる 5ms
✓ |todo-store| src/app/todo/todo.store.spec.ts > TodoStore > 空白だけのタイトルは追加しない 4ms
✓ |todo-store| src/app/todo/todo.store.spec.ts > TodoStore > 更新に失敗したら完了状態を元に戻す 4ms
✓ |todo-store| src/app/todo/todo.store.spec.ts > TodoStore > 読み込みエラーをメッセージにする 3ms
✓ |todo-store| src/app/todo/todo.store.spec.ts > TodoStore > unprotected でテスト用に状態を直接書き換える 3ms
✓ |todo-store| src/app/todo/todo-page.spec.ts > TodoPage(HTTP をモックして画面ごと確認) > 読み込み → 追加 → 表示 62ms
Test Files 2 passed (2)
Tests 7 passed (7)
Duration 1.38s (environment 63%, setup 18%, tests 8%, transform 7%, import 2%, worker 1%)
withHooks の onInit はストアの作成時に動くため、TestBed.inject した時点で読み込みまで終わっています(モックが同期の of() を返すため)。
Playwright で画面の動きを確認する
実際のブラウザで「読み込み → 追加 → 完了 → フィルタ」を操作し、表示を確かめました。 toHaveText は表示が変わるまで自動で待つので、ゾーンレスの画面でも安定して確認できます。
npm i -D @playwright/test
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:4200',
// 手元の Chromium を使う場合は CHROME_BIN にパスを入れる
launchOptions: process.env['CHROME_BIN'] ? { executablePath: process.env['CHROME_BIN'] } : {},
},
webServer: {
command: 'npx ng serve --port 4200',
url: 'http://localhost:4200',
reuseExistingServer: true,
},
});
// e2e/todo.spec.ts
import { expect, test } from '@playwright/test';
test('読み込み・追加・完了・フィルタ', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#loading')).toHaveText('読み込み中…');
const items = page.locator('#list li');
await expect(items).toHaveText(['備品リストを更新する', '会議室を予約する']);
await expect(page.locator('#remaining')).toHaveText('残り 1 件');
await page.fill('#title', '名刺を発注する');
await page.click('#add');
await expect(items).toHaveCount(3);
await expect(page.locator('#remaining')).toHaveText('残り 2 件');
await items.nth(0).locator('input').check();
await expect(page.locator('#remaining')).toHaveText('残り 1 件');
await page.click('[data-filter="done"]');
await expect(items).toHaveText(['備品リストを更新する', '会議室を予約する']);
await page.click('[data-filter="active"]');
await expect(items).toHaveText(['名刺を発注する']);
});
npx playwright test --reporter=list
Running 1 test using 1 worker
✓ 1 e2e/todo.spec.ts:3:5 › 読み込み・追加・完了・フィルタ (1.7s)
1 passed (2.5s)
e2e/ は src/ の外に置いているので、ng test(Vitest)の対象には入りません(ng test --list-tests で src/app/todo/ の2ファイルだけが表示されることを確認)。検証ではポートを変え、既存の Chromium を CHROME_BIN で指定して実行しました。
toHaveText を使わずに click 直後の textContent を読むと、再描画の前の値が取れることがありました。 画面テストでは自動で待つアサーションを使うと安定します。
よくあるつまずきと実際のエラー文
コンポーネントから patchState を呼ぶと型エラーになる
SignalStore の状態は既定で保護されていて、ストアの外から patchState できません。 実際にテストファイルから呼んだときのエラーです。
✘ [ERROR] TS2345: Argument of type '{ entityMap: Signal<EntityMap<Todo>>; ... } & StateSource<...>' is not assignable to parameter of type 'WritableStateSource<{ entityMap: EntityMap<Todo>; ids: EntityId[]; filter: TodoFilter; loading: boolean; error: string | null; }>'.
直し方は、ストアに setFilter のようなメソッドを用意して、それを呼ぶことです。 テストだけで直接書き換えたいときは unprotected(store) を使います。どうしても外から更新したい場合は signalStore({ protectedState: false }, ...) も選べますが、更新の入口がメソッドに集まっているほうが読みやすくなります。
_ で始まるメンバーは外から読めない
✘ [ERROR] TS2339: Property '_api' does not exist on type '{ entityMap: Signal<EntityMap<Todo>>; ... } & StateSource<...>'.
_api はストア内部専用として隠されています。 外から使いたいものは _ を付けずに定義します。
チェックリスト
| 確認すること | 理由 |
|---|---|
withEntities の型に id プロパティがある(なければ entityConfig の selectId) |
ID で一覧を管理するため |
読み込みは switchMap、追加・更新は concatMap |
取り消しと順序の考え方が違うため |
エラーは tapResponse の error で状態にする |
Observable が止まらず、次の呼び出しも動くため |
表示用の値は withComputed で作る |
状態を二重に持たないため |
画面テストは自動で待つアサーション(toHaveText など) |
ゾーンレスの再描画のタイミングに合わせるため |
よくある質問
Q. SignalStore は providedIn: 'root' にしないといけませんか?
A. いいえ。{ providedIn: 'root' } を外すと、コンポーネントの providers: [TodoStore] に書いて画面ごとにストアを作れます。画面を離れるとストアも破棄され、withHooks の onDestroy が動きます。
Q. 従来の NgRx Store から移行する必要はありますか? A. 必須ではありません。両方を同じアプリで使えます。新しく作る機能や、画面単位で閉じた状態から SignalStore を使い始めると、無理なく取り入れられます。
Q. rxMethod の代わりに async/await で書けますか?
A. 書けます。withMethods の中に async load() { ... patchState(...) } のような普通のメソッドを定義できます。取り消し(switchMap)や順番の制御(concatMap)が必要な処理は rxMethod が短く書けます。
まとめ
- NgRx SignalStore は
signalStore()にwithState・withComputed・withMethodsを重ねて作る 状態管理 - 状態の更新は
patchState、一覧はwithEntitiesとsetAllEntities・addEntity・updateEntity - 非同期処理は
rxMethod+tapResponse。読み込みはswitchMap、追加・更新はconcatMap - 依存は
withPropsの_apiで隠し、初期読み込みはwithHooksのonInitで行う - 状態は既定で保護され、外からの
patchStateは型エラー。テストではunprotectedを使う - Vitest でストア単体と画面を、Playwright で実ブラウザの動き を確かめられる
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「NgRx SignalStore の使い方|v22でTODOアプリを全コード解説」
株式会社RJCでは、NgRx SignalStore のような状態管理の新しい選択肢も、小さなアプリで試しながらチームで学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。