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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?