Signalsと状態管理
Angular SignalsはReactのuseStateに近い概念であり、Angular 16以降で導入された新しいリアクティビティモデルです。
1. Signalsの基本
Signal は「値を保持し、変更を通知できるラッパー」です。テンプレートや computed / effect から読み取られると、Angular はどこでその値が使われているかを追跡し、値が変わったときに 必要な箇所だけ を更新します。
signal() の作成
import { signal } from '@angular/core';
const count = signal(0); // WritableSignal<number>
const user = signal<User | null>(null);
値の読み取りと更新(set, update)
Signal は 関数 です。値を読むときは count() のように呼び出します。
count(); // 読み取り → 0
count.set(5); // 値を直接置き換える
count.update((n) => n + 1); // 現在値から新しい値を計算する
オブジェクトや配列は イミュータブルに更新 します。
const todos = signal<Todo[]>([]);
todos.update((list) => [...list, { id: 1, title: '買い物', done: false }]);
todos.update((list) =>
list.map((t) => (t.id === 1 ? { ...t, done: true } : t)),
);
Angular 16 の開発者プレビューには mutate() がありましたが、Angular 17 で削除されました。配列やオブジェクトを直接書き換える方式は、変更検知の判定や computed のメモ化と相性が悪いためです。現在は set() と update() のみを使い、常に新しい参照を作って更新します。
Signal は同じ値(Object.is で等しい値)をセットしても通知しません。独自の等価判定が必要な場合は equal オプションを渡せます。
const point = signal({ x: 0, y: 0 }, {
equal: (a, b) => a.x === b.x && a.y === b.y,
});
コンポーネントでの利用
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+1</button>
`,
})
export class CounterComponent {
count = signal(0);
increment(): void {
this.count.update((n) => n + 1);
}
}
外部に公開したくない場合は、asReadonly() で読み取り専用の Signal を公開します。
private readonly _count = signal(0);
readonly count = this._count.asReadonly();
React の useState との比較
| 観点 | Angular Signal | React useState |
|---|---|---|
| 作成 | signal(0) | useState(0) |
| 読み取り | count()(関数呼び出し) | count(値そのもの) |
| 更新 | set() / update() | setCount(v) / setCount(fn) |
| 定義できる場所 | コンポーネント外(サービス・モジュール)でも可 | コンポーネント/フック内のみ |
| 再描画 | 依存するテンプレート箇所だけ更新 | コンポーネント関数が再実行される |
| 依存の宣言 | 自動追跡(配列不要) | 依存配列を手書き |
React ではレンダリングのたびに関数が再実行されるのに対し、Angular のコンポーネントは一度だけ生成され、Signal の変化がテンプレートの該当部分に伝わります。この違いにより、Signal は フックのルールの制約がなく、サービスなど任意の場所で使えます。
2. Computed Signals
computed() による派生状態
computed() は、他の Signal から導出される 読み取り専用の Signal です。
import { signal, computed } from '@angular/core';
@Component({
selector: 'app-todo-summary',
template: `
<p>{{ remaining() }} / {{ todos().length }} 件が未完了</p>
@for (t of visibleTodos(); track t.id) {
<li>{{ t.title }}</li>
}
`,
})
export class TodoSummaryComponent {
todos = signal<Todo[]>([]);
filter = signal<'all' | 'active'>('all');
remaining = computed(() => this.todos().filter((t) => !t.done).length);
visibleTodos = computed(() =>
this.filter() === 'all'
? this.todos()
: this.todos().filter((t) => !t.done),
);
}
computed は次の特性を持ちます。
- 遅延評価: 読み取られるまで計算されません。
- メモ化: 依存する Signal が変わらない限り、前回の結果を再利用します。
- 読み取り専用:
set()は呼べません。
自動的な依存関係追跡
computed の関数内で 実際に呼び出された Signal が依存先として自動で記録されます。条件分岐があれば、通った分岐の Signal だけが依存になります。
const showDetail = signal(false);
const detail = signal('詳細');
const label = computed(() => (showDetail() ? detail() : '非表示'));
// showDetail が false の間は detail() が変わっても再計算されない
React の useMemo との対比
| 観点 | computed() | useMemo |
|---|---|---|
| 依存の指定 | 自動追跡 | 依存配列を手書き(漏れるとバグ) |
| 評価タイミング | 読み取り時(遅延) | レンダリング時 |
| 保証 | 依存が変わらなければ再計算しない | パフォーマンスヒントであり保証ではない |
| 定義場所 | 任意(クラスフィールドなど) | コンポーネント/フック内 |
React の useMemo / useCallback で頻発する「依存配列の書き忘れ」が、Signals では構造的に起こりません。
linkedSignal(補足)
Angular 19 以降の linkedSignal() は、他の Signal に連動してリセットされつつ、ユーザー操作で上書きもできる「書き込み可能な派生 Signal」です。例えば、選択肢のリストが変わったら選択を先頭にリセットする、といった用途に使えます。
const options = signal(['A', 'B', 'C']);
const selected = linkedSignal(() => options()[0]);
selected.set('B'); // ユーザーが選ぶ
options.set(['X', 'Y']); // 元が変わると selected は 'X' にリセットされる
3. Effects
effect() による副作用の実行
effect() は、読み取った Signal が変化するたびに実行される副作用です。ログ出力、localStorage への保存、サードパーティのライブラリ連携など、Signal の外の世界に反映する処理 に使います。
import { Component, effect, signal } from '@angular/core';
@Component({ selector: 'app-theme', template: `...` })
export class ThemeComponent {
theme = signal<'light' | 'dark'>('light');
constructor() {
// 注入コンテキスト(コンストラクタやフィールド初期化)で作成する
effect(() => {
document.body.dataset['theme'] = this.theme();
localStorage.setItem('theme', this.theme());
});
}
}
effectは 注入コンテキスト で作成する必要があります(コンストラクタ、フィールド初期化子、runInInjectionContext内)。コンポーネント破棄時に自動で破棄されます。- 最初の実行は作成後の変更検知のタイミングで行われ、以降は依存する Signal が変わるたびに非同期でスケジュールされます。
- Angular 20 で
effectは安定版になりました。
クリーンアップ処理
effect 関数は onCleanup コールバックを受け取り、次の実行前 または破棄時に呼ばれる後始末を登録できます。
effect((onCleanup) => {
const id = this.userId();
const timer = setInterval(() => console.log('polling', id), 5000);
onCleanup(() => clearInterval(timer));
});
Signal の書き込みに関する注意
effect の中で別の Signal を更新すると、無限ループや分かりにくいデータフローの原因になります。Angular 18 までは既定で禁止され allowSignalWrites オプションが必要でしたが、Angular 19 以降は書き込みが許可されています。ただし、派生した値は effect で同期するのではなく computed / linkedSignal で表現する のが原則です。
// 悪い例: effect で派生状態を同期する
effect(() => this.fullName.set(`${this.first()} ${this.last()}`));
// 良い例: computed で表現する
fullName = computed(() => `${this.first()} ${this.last()}`);
React の useEffect との比較と違い
| 観点 | Angular effect() | React useEffect |
|---|---|---|
| 依存の指定 | 自動追跡 | 依存配列を手書き |
| 実行タイミング | 依存が変わったとき(変更検知の過程で) | レンダリング後(依存配列の変化時) |
| クリーンアップ | onCleanup 引数で登録 | 戻り値の関数 |
| 初回実行 | 作成後に実行 | マウント後に実行 |
| 主な用途 | Signal を外部へ同期(DOM、ストレージ、ログ) | データ取得・購読・DOM操作など全般 |
| 非同期の依存 | await の後で読んだ Signal は追跡されない | 配列で明示 |
重要な違いとして、useEffect は「レンダリング後に必ず走る何でも屋」として使われがちですが、Angular の effect は 限定的な用途 のための機能です。データ取得は HttpClient + toSignal や resource で、派生値は computed で書くのが推奨です。
4. Signal-based Components
コンポーネントの入出力・クエリも Signal ベースの API に置き換わっています。Angular 17.1 以降で導入され、19 で安定版になりました。
input() / output() / model()
import { Component, input, output, model, computed, numberAttribute } from '@angular/core';
@Component({
selector: 'app-rating',
template: `
<button (click)="rate(value() + 1)">+</button>
<span>{{ label() }}: {{ value() }}</span>
`,
})
export class RatingComponent {
label = input('評価'); // 省略可能(既定値付き)
max = input.required<number>(); // 必須
step = input(1, { transform: numberAttribute }); // 値の変換
value = model(0); // 双方向バインディング
changed = output<number>(); // イベント
protected remaining = computed(() => this.max() - this.value());
rate(v: number): void {
this.value.set(v);
this.changed.emit(v);
}
}
<app-rating [max]="5" [(value)]="score" (changed)="onChanged($event)" />
input()は 読み取り専用の Signal を返します。computedの中で直接使えます。model()は書き込み可能な Signal で、[(value)]による双方向バインディングに対応します。output()は RxJS のSubjectではなく専用のOutputEmitterRefを返し、emit()で発火します。
viewChild / viewChildren / contentChild / contentChildren
import { Component, viewChild, viewChildren, ElementRef, effect } from '@angular/core';
@Component({
selector: 'app-form',
template: `
<input #nameInput />
@for (item of items; track item) {
<app-row [item]="item" />
}
`,
})
export class FormComponent {
items = ['a', 'b', 'c'];
nameInput = viewChild.required<ElementRef<HTMLInputElement>>('nameInput');
rows = viewChildren(RowComponent);
constructor() {
effect(() => console.log('rows:', this.rows().length));
}
focus(): void {
this.nameInput().nativeElement.focus();
}
}
viewChild() は Signal<T | undefined>、viewChild.required() は見つからないとエラーになる Signal<T> を返します。@ViewChild と異なり 結果が Signal なので、ngAfterViewInit を待たずに computed / effect で自然に扱えます。contentChild / contentChildren も同様に、<ng-content> で投影された子要素を取得します。
@Input / @Output デコレータからの移行
| 従来 | Signal ベース |
|---|---|
@Input() name: string | name = input<string>() |
@Input({ required: true }) id!: number | id = input.required<number>() |
@Output() clicked = new EventEmitter<void>() | clicked = output<void>() |
@Input() + @Output() valueChange の組 | value = model<T>() |
@ViewChild('x') el | el = viewChild<ElementRef>('x') |
@ViewChildren(C) | items = viewChildren(C) |
@ContentChild / @ContentChildren | contentChild() / contentChildren() |
ngOnChanges で変更検知 | computed / effect |
移行は Angular CLI のスキーマティックで自動化できます。
ng generate @angular/core:signal-input-migration
ng generate @angular/core:output-migration
ng generate @angular/core:signal-queries-migration
React との対比では、input() は props、output() はコールバック props(onChanged)、model() は「値 + onChange」の組に相当します。
5. RxJSとの相互運用
Signals は「現在の値」を扱うのに適しており、RxJS は「時間軸上のイベントの流れ」を扱うのに適しています。両者は @angular/core/rxjs-interop で相互変換できます。
toSignal(): Observable → Signal
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-user-list',
template: `
@for (u of users(); track u.id) {
<li>{{ u.name }}</li>
} @empty {
<li>ユーザーがいません</li>
}
`,
})
export class UserListComponent {
private http = inject(HttpClient);
users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });
}
- 購読は自動で行われ、コンポーネント(注入コンテキスト)の破棄時に自動解除されます。
asyncパイプも不要です。 initialValueを指定しない場合の型はSignal<T | undefined>になります。同期的に値を出さない Observable ではinitialValueを指定してください。- Observable がエラーを出すと、Signal の読み取り時にそのエラーが投げられます。
toObservable(): Signal → Observable
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap } from 'rxjs';
query = signal('');
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
switchMap((q) => this.http.get<Item[]>('/api/search', { params: { q } })),
),
{ initialValue: [] },
);
toObservable は effect を使って値を流すため、同期的ではなく、変更検知のタイミングで最新値が送出 されます。複数回の更新はまとめられ、最後の値だけが届く場合があります。
使い分けの指針
| 状況 | 推奨 |
|---|---|
| UI の状態・派生値 | Signal / computed |
| 単発の HTTP 取得 | HttpClient の Observable → toSignal |
デバウンス、switchMap、リトライなど時間軸の制御 | RxJS |
| WebSocket など連続したストリーム | RxJS → toSignal で UI に接続 |
| テンプレートからの参照 | Signal(async パイプより簡潔) |
| フォームの値変化 | valueChanges(Observable)→ 必要なら toSignal |
原則は、状態は Signal、非同期の流れの合成は RxJS、境界で変換する ことです。なお、resource() / httpResource()(Angular 19 以降、実験的)のように、非同期データ取得を Signal として直接扱う API も導入されつつあります。
6. NgRxによる大規模状態管理
NgRx Store の概要
NgRx は Redux パターンを Angular + RxJS で実装した状態管理ライブラリです。
| 要素 | 役割 |
|---|---|
| Store | アプリ全体の単一の状態ツリー |
| Action | 「何が起きたか」を表すイベント |
| Reducer | 現在の状態と Action から新しい状態を返す純粋関数 |
| Selector | Store から必要な値を取り出す(メモ化された)関数 |
| Effects | HTTP 通信などの副作用を扱う(RxJS ベース) |
// todos.actions.ts
import { createActionGroup, props, emptyProps } from '@ngrx/store';
export const TodoActions = createActionGroup({
source: 'Todos',
events: {
'Add': props<{ title: string }>(),
'Toggle': props<{ id: number }>(),
'Clear Completed': emptyProps(),
},
});
// todos.reducer.ts
import { createFeature, createReducer, on } from '@ngrx/store';
interface State { todos: Todo[]; }
const initialState: State = { todos: [] };
export const todosFeature = createFeature({
name: 'todos',
reducer: createReducer(
initialState,
on(TodoActions.add, (state, { title }) => ({
...state,
todos: [...state.todos, { id: Date.now(), title, done: false }],
})),
on(TodoActions.toggle, (state, { id }) => ({
...state,
todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
})),
),
});
// コンポーネントでの利用
private store = inject(Store);
todos = this.store.selectSignal(todosFeature.selectTodos); // Signal として取得
add(title: string) { this.store.dispatch(TodoActions.add({ title })); }
// app.config.ts
provideStore({ [todosFeature.name]: todosFeature.reducer }),
provideEffects(),
Redux との類似点・相違点
| 観点 | NgRx | Redux (Redux Toolkit) |
|---|---|---|
| 基本モデル | Action → Reducer → Store | 同じ |
| 状態の公開 | Observable / Signal(selectSignal) | useSelector |
| 副作用 | @ngrx/effects(RxJS) | Thunk / Listener Middleware |
| DI | Angular の DI で Store を注入 | Provider + フック |
| 型 | Action/Selector が強く型付けされる | RTK で同様に型付け可能 |
| DevTools | Redux DevTools 対応 | 対応 |
| ボイラープレート | 多い(Action/Reducer/Selector/Effects) | RTK の createSlice で少なめ |
NgRx SignalStore(新しい Signal ベース Store)
@ngrx/signals の SignalStore は、Signals を前提にした軽量な状態管理です。Action / Reducer を書かずに、状態・派生値・メソッドを1か所で宣言できます。
import { computed } from '@angular/core';
import {
signalStore, withState, withComputed, withMethods, patchState,
} from '@ngrx/signals';
interface TodoState {
todos: Todo[];
filter: 'all' | 'active';
}
export const TodoStore = signalStore(
{ providedIn: 'root' },
withState<TodoState>({ todos: [], filter: 'all' }),
withComputed(({ todos, filter }) => ({
visible: computed(() =>
filter() === 'all' ? todos() : todos().filter((t) => !t.done),
),
remaining: computed(() => todos().filter((t) => !t.done).length),
})),
withMethods((store) => ({
add(title: string): void {
patchState(store, (s) => ({
todos: [...s.todos, { id: Date.now(), title, done: false }],
}));
},
setFilter(filter: TodoState['filter']): void {
patchState(store, { filter });
},
})),
);
@Component({
selector: 'app-todos',
template: `
<p>残り {{ store.remaining() }} 件</p>
@for (t of store.visible(); track t.id) { <li>{{ t.title }}</li> }
`,
})
export class TodosComponent {
readonly store = inject(TodoStore);
}
小規模アプリでの代替手段(サービス + Signals)
多くのアプリでは、NgRx を導入しなくても サービスに Signal を持たせる だけで十分です。
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly total = computed(() =>
this._items().reduce((sum, i) => sum + i.price * i.qty, 0),
);
add(item: CartItem): void {
this._items.update((list) => [...list, item]);
}
clear(): void {
this._items.set([]);
}
}
選択の目安
| 規模・要件 | 推奨 |
|---|---|
| コンポーネント内のローカル状態 | signal() |
| 複数コンポーネントでの共有(小〜中規模) | サービス + Signals |
| 機能ごとの構造化された状態、メソッドの整理 | NgRx SignalStore |
| 大規模・厳格なイベント履歴・タイムトラベルデバッグ | NgRx Store |
7. コンポーネント間の状態共有パターン
親子間: Input/Output
親から子へは input()、子から親へは output()(または model())で、データの流れを明示します。
// 親
@Component({
selector: 'app-parent',
imports: [ChildComponent],
template: `
<app-child [items]="items()" (selected)="onSelected($event)" />
`,
})
export class ParentComponent {
items = signal<Item[]>([]);
onSelected(item: Item): void { /* ... */ }
}
兄弟間: 共有サービス
共通の親を介さずに状態を共有するには、providedIn: 'root'(または親コンポーネントの providers)のサービスに Signal を置きます。前述の CartService のように、各コンポーネントが inject() して読み書きします。
@Component({ selector: 'app-cart-badge', template: `カート: {{ cart.items().length }}` })
export class CartBadgeComponent {
readonly cart = inject(CartService);
}
@Component({ selector: 'app-product', template: `<button (click)="buy()">追加</button>` })
export class ProductComponent {
private readonly cart = inject(CartService);
buy(): void { this.cart.add({ id: 1, price: 100, qty: 1 }); }
}
サービスのスコープは、providedIn: 'root'(アプリ全体)か、コンポーネントの providers(そのコンポーネントとその子孫のみ、インスタンスも別)で制御できます。
グローバル: Store パターン
アプリ全体で共有し、変更の流れを一元管理したい場合は NgRx Store / SignalStore を使います。ログインユーザー、権限、テーマ設定のようなグローバルな状態が典型です。
React の Context, Redux, Zustand などとの対比
| Angular | React での近い手段 | 備考 |
|---|---|---|
input() / output() | props / コールバック props | 同じ発想 |
サービス + Signals(providedIn: 'root') | Zustand / Jotai | モジュールスコープのストア |
コンポーネントの providers で提供したサービス | Context Provider | 配下のツリーに限定したスコープ |
| NgRx Store | Redux Toolkit | Action/Reducer の厳格なフロー |
| NgRx SignalStore | Zustand(構造化版) | 状態・派生値・メソッドを一か所に |
React では「Context は再レンダリングの範囲が広くなりがちなので、頻繁に変わる状態には外部ストアを使う」といった判断が必要です。Angular では Signal がテンプレートの細かい箇所だけを更新するため、サービス + Signals で十分に細粒度な共有ができ、DI の階層でスコープも柔軟に制御できます。