Angular上級 #2 Signals 深掘り — computed、effect、model

読了 10分

基礎 #3 では signal() でカウンターを作ってみて、中級 #3 では RxJS を整理しながら toSignal で 2 つの世界をつなぐ絵までを描いてみました。これまでシグナルは「値を関数のように呼び出して取り出すリアクティブ変数」程度にしか扱ってきませんでした。今回は後回しにしていた Signals の内側 を本格的に覗き込んでみます。computedeffect、そしてコンポーネント API の新しい顔である input()output()model()、さらに linkedSignal まで一箇所に整理します。

Signals がどこに立っているか #

Signals は Angular 16 にプレビューで入って 17 で安定化し、今やモダン Angular の 標準リアクティブモデル として定着しました。これは Angular だけの流れではありません。SolidJS が先に道を切り開き、Vue 3 の ref/computed も同じ形で、Svelte 5 の $state/$derived ルーンも同じ家族です。フレームワークたちが申し合わせたかのように同じ方向に集まっています。

理由はシンプルです。シグナルは 「何が何に依存しているか」をランタイムが正確に知っている という点で、変更検知を精密にし、コードを追跡しやすくしてくれます。Angular の立場では、長く支えてきた Zone.js ベースの変更検知の負担を下ろし、未来の Zoneless モードへ向かう橋でもあります。

signal() を再び見る — set、update、asReadonly #

基礎で見たそのままですが、もう一段だけ深く覗き込みます。

src/app/counter.component.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>現在: {{ count() }}</p>
    <button (click)="inc()">+1</button>
  `,
})
export class CounterComponent {
  private _count = signal(0);
  count = this._count.asReadonly();

  inc() {
    this._count.update(v => v + 1);
  }
}

ポイントは 3 つです。

  • set(value) で値を丸ごと変え、update(prev => next) で以前の値を受け取って新しい値を作ります。
  • asReadonly()外部に読み取り専用シグナル だけを公開できます。RxJS の Subject/asObservable() パターンと正確に同じ発想です。
  • シグナルの変化追跡の単位は 参照(reference) です。オブジェクト内のフィールドを直接変更してもシグナルは知りません。新しいオブジェクトを set するか、update(prev => ({ ...prev, ... })) で差し替える必要があります。

最後の項目は慣れていないとよくつまずく所です。シグナルは「変わった」を基本的に === で判断するため、同じ参照を再度入れると変化として見ません。

computed() — 派生値とメモ化 #

computed他のシグナルに依存する派生値 です。依存しているシグナルが変わったときにだけ自動的に再計算され、そうでなければキャッシュされた値をそのまま返します。

src/app/cart.component.ts
import { Component, computed, signal } from '@angular/core';

interface Item { name: string; price: number; qty: number; }

@Component({
  selector: 'app-cart',
  standalone: true,
  template: `
    <p>合計: {{ total() }}円 ({{ count() }}個)</p>
    <p>送料込み: {{ totalWithShipping() }}円</p>
  `,
})
export class CartComponent {
  items = signal<Item[]>([
    { name: 'りんご', price: 1000, qty: 3 },
    { name: '梨', price: 2000, qty: 2 },
  ]);

  count = computed(() => this.items().reduce((s, i) => s + i.qty, 0));
  total = computed(() => this.items().reduce((s, i) => s + i.price * i.qty, 0));
  totalWithShipping = computed(() => this.total() + (this.total() >= 10000 ? 0 : 3000));
}

totalWithShippingtotal を通じて結局 items だけに依存します。items がそのままなら、テンプレートで何度呼んでも計算は 1 回だけ起こります。依存関係は 関数本体の中でシグナルを読む瞬間 に自動で登録されます。別途「これを追跡しろ」と書く必要はありません。

ルール 1 つだけ覚えておけば大丈夫です — computed の中では副作用を起こさないでください。HTTP 呼び出し、コンソールログ、他のシグナルの set のようなものは絶対に禁止です。メモ化のせいで呼び出しが省略されたり、複数回実行されてもおかしくないところだからです。

effect() — シグナルの変化に副作用を掛ける #

副作用を起こす必要があるときは effect を使います。computed が「値を作る」なら effect は「仕事をする」です。

src/app/preferences.component.ts
import { Component, effect, signal } from '@angular/core';

@Component({ /* ... */ })
export class PreferencesComponent {
  theme = signal<'light' | 'dark'>('light');

  constructor() {
    effect(() => {
      const t = this.theme();
      document.documentElement.dataset['theme'] = t;
      localStorage.setItem('theme', t);
    });
  }
}

effect は登録時点に 1 回実行され、その後は本体で読んだシグナルが変わるたびに再実行されます。依存関係の追跡は computed と同じく自動です。

effect は基本的に コンポーネント(あるいはディレクティブ、サービス)の注入コンテキスト に紐づいている必要があります。なので普通は constructor やフィールド初期化で呼び出します。コンポーネントが消えると自動で片付けられるので、RxJS のように unsubscribe を気にする必要がありません。

cleanup が必要な場合はコールバックの引数で受け取ります。

cleanup のある effect
constructor() {
  effect(onCleanup => {
    const id = setInterval(() => console.log(this.count()), 1000);
    onCleanup(() => clearInterval(id));
  });
}

onCleanup に登録した関数は 次の実行直前とコンポーネント破棄時点に 呼び出されます。setInterval/addEventListener のように直接触るリソースがあるときだけ気にすればよいです。

注記
effect はライフサイクルフックではありません。ngOnInit のように「一度だけ初期化」用途で使わないでください。effect の居場所はあくまで シグナル → 外部の世界 です。localStorage 同期、ロギング、外部ライブラリ同期のようなものが良い例です。

Signal ベースのコンポーネント API — input、output、viewChild #

Angular 17.1 からはデコレータの代わりに関数でコンポーネントの入出力を宣言する新 API が入りました。シグナルと自然に馴染む形です。

src/app/user-card.component.ts
import { Component, input, output, computed } from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <h3>{{ greeting() }}</h3>
    <button (click)="select.emit(name())">選択</button>
  `,
})
export class UserCardComponent {
  name = input.required<string>();
  prefix = input<string>('様');

  select = output<string>();

  greeting = computed(() => `${this.name()}${this.prefix()}、いらっしゃいませ!`);
}

比較してみると差がはっきりします。

デコレータ方式シグナルベース
入力@Input() name!: string;name = input.required<string>();
出力@Output() select = new EventEmitter<string>();select = output<string>();
子参照@ViewChild('x') x!: ElementRef;x = viewChild<ElementRef>('x');
使用this.namethis.name()

新 API の最大の利得は 入力がそのままシグナル だという点です。computed で自然に派生値を作ることができ、親が変えた入力値が子の effect までそのまま流れていきます。変更検知がシグナルグラフの上で一貫して動作するわけです。

input.required<T>() は親が必ず値を渡さなければならないという意味で、抜けるとコンパイルエラーで捕まえてくれます。昔のデコレータ時代に ! で型を欺かなければならなかった所がきれいになりました。

model() — 双方向バインディングが standalone できれいになる #

[(ngModel)] はフォームに閉じ込められていましたが、一般のコンポーネント同士でも双方向が必要なときがあります。以前は @Input value + @Output valueChange で手作業でペアを合わせる必要があったのを、model() がこれを 1 行で整理してくれます。

src/app/toggle.component.ts
import { Component, model } from '@angular/core';

@Component({
  selector: 'app-toggle',
  standalone: true,
  template: `
    <button (click)="flip()">{{ checked() ? 'オン' : 'オフ' }}</button>
  `,
})
export class ToggleComponent {
  checked = model.required<boolean>();

  flip() {
    this.checked.update(v => !v);
  }
}

親は [(checked)] の 1 行で双方向をつなぎます。

parent.component.html
<app-toggle [(checked)]="isOn" />

model は内部的に input + output + update 可能シグナルが組み合わされた形です。子が update/set で値を変えると親側のシグナルも一緒に更新されます。FormsModulengModel も必要ありません。

linkedSignal — 入力に連動したローカル加工シグナル (Angular 19+) #

親の入力値を初期値として始めつつ、ユーザーが編集できなければならず、親が新しい値を流せばまたその値にリセットされなければならない — 以前は ngOnChanges で手作業で同期していた所を linkedSignal が 1 行で終わらせます。

src/app/editable-name.component.ts
import { Component, input, linkedSignal } from '@angular/core';

@Component({ /* ... */ })
export class EditableNameComponent {
  initialName = input.required<string>();

  draft = linkedSignal(() => this.initialName());

  reset() { this.draft.set(this.initialName()); }
}

draft は普通の書き込み可能シグナルのように動作しますが、initialName が変わると自動でその値に再同期 されます。computed のように依存関係を追跡しながら signal のように使えるハイブリッドです。

Signal と RxJS の連携 — toSignal / toObservable #

中級 #3 で申し上げた絵がここで完成します。2 つの世界は敵ではなく それぞれ得意な領域 が違うだけです。

src/app/users.component.ts
import { Component, inject, signal, computed } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
import { UserService } from './user.service';

@Component({ /* ... */ })
export class UsersComponent {
  private users = inject(UserService);

  query = signal('');
  // シグナル → Observable → HTTP → シグナル
  results = toSignal(
    toObservable(this.query).pipe(
      switchMap(q => this.users.search(q)),
    ),
    { initialValue: [] },
  );

  count = computed(() => this.results().length);
}

流れに沿って読めばシンプルです。

  1. query はシグナルです。入力が変わると即座に反応します。
  2. toObservable がシグナルの変化を Observable ストリームに流します。
  3. switchMap が非同期検索を RxJS の領域で処理します — 以前のリクエストのキャンセルまで 1 行で。
  4. 結果は toSignal で再びシグナルになり、テンプレートと computed に流れていきます。

ガイドラインはシンプルです — UI 状態と派生値はシグナル、非同期ストリームと operator の組み合わせは RxJS。境界では 2 つの変換関数が橋を渡します。

untracked と equality — 精密な制御 #

ときには 読みはするが依存はしたくない シグナルがあります。effect の中で他のシグナルを参照だけしたいのに、そのシグナルが変わるたびに effect が再実行されると困ります。こんなときに untracked を使います。

untracked で依存を切る
import { effect, untracked } from '@angular/core';

constructor() {
  effect(() => {
    const id = this.userId();           // 追跡
    const region = untracked(() => this.region()); // 読み取りのみ
    this.fetchProfile(id, region);
  });
}

こうすると userId が変わったときだけ effect が再び回り、region はその瞬間の最新値を借りてくるだけです。

比較関数を変えたい場合は equal オプションを使います。

オブジェクト比較を深く
import { signal } from '@angular/core';
import { isEqual } from 'lodash-es';

const filter = signal({ q: '', tag: null }, { equal: isEqual });

デフォルトの === の代わりに深い比較を使うと、同じ形の新しいオブジェクトを set しても変化として見ません。下流の computed/effect の不要な再実行を減らせます。ただし、すべてのシグナルに深い比較を掛けるのはやり過ぎです — 再実行が実際に気になるところ だけに選択的に使います。

よく出会う失敗 #

初めてシグナルを本格的に使うときによく引っかかる 3 つの落とし穴です。

1. effect の中でシグナルを set しています。 デフォルトでブロックされています。本当に必要なら { allowSignalWrites: true } で解除できますが、99% の場合は それが必要なのではなく computed が必要なだけ です。

2. computed の中で副作用を起こす。 computed はメモ化のせいで実行回数を保証しません。console.log も他のシグナルの set も絶対に禁止です。副作用は effect に置いてください。

3. オブジェクトを直接変更しています。

悪い例 vs 良い例
this.user().name = '新しい名前'; // ❌ シグナルが知らない
this.user.update(u => ({ ...u, name: '新しい名前' })); // ✅ 新しい参照

参照が変わってこそシグナルが変化として認識します。不変更新が答えです。

ヒント
新たにシグナルを使い始めると、「値をどう変えるか?」ではなく「これは signal か、computed か、effect か?」をまず問う習慣をつけてください。3 つのうち 1 つに正確に落ちればコードはきれいになります。どこに置くべきか迷ったらほとんどの場合 computed が正解です。

まとめ #

今回は Signals を本格的に覗き込みました。

  • signal は変化追跡の単位。set/update/asReadonly
  • computed は自動追跡される派生値。副作用禁止、メモ化される
  • effect はシグナル → 外部の世界。cleanup コールバック対応、ライフサイクルフックではない
  • input()/output()/model()/viewChild() でコンポーネント API がシグナルの上に統合
  • model() で standalone コンポーネントの双方向バインディングがすっきり
  • linkedSignal で入力に連動したローカル編集可能シグナル (Angular 19+)
  • toSignal/toObservable で RxJS と自由に行き来
  • untracked で依存を切る、equal で比較をカスタム

次回は 「Angular上級 #3 動的コンポーネントと Portal」 です。ランタイムにコンポーネントを作って表示し、モーダル・ツールチップ・オーバーレイを直接構成する動的コンポーネント API と、Angular CDK の Portal・Overlay を扱います。

X