Angular Signals(シグナル)とは、値が変わったことをAngularに自動で知らせる「変化を追跡できる値の入れ物」です。 signal() で値を持ち、computed() で計算し、effect() で副作用を実行する、の3つが基本です。Angular 22 では新規コンポーネントの変更検知が OnPush(必要なところだけ再描画する方式)が既定 になったため、シグナルで状態を持つことが「画面を正しく更新するための標準の書き方」になりました。

この記事では、Angular 22.2.0(2026-09-23 リリース)で新規プロジェクトを作り、すべてのコードを実際にビルド・実行して確認 しています。signal / computed / effect から linkedSignal、untracked、input() / model() / output()、viewChild などのシグナルクエリ、resource までを早見表にまとめ、Playwright(Chromium)でクリックした後の画面の値 をそのまま載せています。「配列に push しても件数が変わらない」「effect が無限ループする」「OnPush で画面が更新されない」といった、つまずきやすい挙動も実測結果で解説します。

Angular Signals APIの早見表(v22.2時点)

やりたいこと API 書き方の例 v22での状態
値を持つ(読み書きできる) signal() count = signal(0) → count() で読む 安定版
値を置き換える .set() count.set(5) 安定版
今の値から更新する .update() count.update(c => c + 1) 安定版
他のシグナルから計算する(読み取り専用) computed() double = computed(() => count() * 2) 安定版
値が変わったら処理を実行する effect() effect(() => console.log(count())) 安定版(v20〜)
元の値に連動しつつ手動でも書き換える linkedSignal() selected = linkedSignal(() => options()[0]) 安定版(v20〜)、v22.1で set オプション追加
依存関係に含めずに読む untracked() untracked(() => other()) 安定版(v20〜)
親から値を受け取る input() / input.required() name = input.required<string>() 安定版
親と双方向でやり取りする model() qty = model(1) → [(qty)] 安定版(v19〜)
親へイベントを送る output() removed = output<string>() 安定版(v19〜)
テンプレートの要素を取得 viewChild() / viewChildren() viewChild.required<ElementRef>('search') 安定版(v19〜)
子コンテンツを取得 contentChild() / contentChildren() contentChild(TabComponent) 安定版(v19〜)
非同期データを読み込む resource() / httpResource() / rxResource() resource({ params, loader }) v22で安定版
resource を連結する params: ({ chain }) => ... chain(userResource) v22で追加
値の変化を遅らせる(デバウンス) debounced() debounced(() => query(), 300) v22で追加(実験的)
Observable と相互変換 toSignal() / toObservable() toSignal(obs$, { initialValue: [] }) 安定版
シグナルかどうか判定 isSignal() isSignal(x) 安定版

迷ったら「値は signal、計算は computed、外の世界(ログ・保存・DOM操作)への反映だけ effect」 と覚えると、ほとんどの場面で正しい選択になります。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular Signals使い方|v22対応の全API早見表と実行結果つきコード」

検証環境

項目 バージョン
Angular(core / CLI) 22.2.0
TypeScript 6.0.3
Node.js 22.22.3
テスト Vitest 5.0.2(ng test の既定)
ブラウザ確認 Playwright 1.56.1 + Chromium

プロジェクトは次のコマンドで作成しました。

npx -y -p @angular/cli@22 ng new sig22 --skip-git --style=css --ssr=false --defaults --interactive=false

Angular CLI 22.2 は Node.js 22.22.3 以上(または 24.15.0 以上 / 26.0.0 以上)が必要 です。Node.js 22.22.2 で実行したところ、次のメッセージで止まりました。

Node.js version v22.22.2 detected.
The Angular CLI requires a minimum Node.js version of v22.22.3 or v24.15.0 or v26.0.0.

signal・computed・effect の基本を動かす

まずは3つの基本APIを1つのコンポーネントで試します。

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

@Component({
  selector: 'app-counter',
  template: `
    <section id="counter">
      <p>count: <b class="count">{{ count() }}</b></p>
      <p>double: <b class="double">{{ double() }}</b></p>
      <p>label: <b class="label">{{ label() }}</b></p>
      <button class="inc" (click)="increment()">+1</button>
      <button class="reset" (click)="count.set(0)">リセット</button>
    </section>
  `,
})
export class Counter {
  // 1. signal:書き込みできる値の入れ物
  protected readonly count = signal(0);

  // 2. computed:他のシグナルから計算される読み取り専用の値(キャッシュされる)
  protected readonly double = computed(() => this.count() * 2);
  protected readonly label = computed(() => (this.count() >= 3 ? '3以上' : '3未満'));

  constructor() {
    // 3. effect:シグナルが変わったときに副作用(ログ・保存など)を実行
    effect(() => {
      console.log(`[effect] count=${this.count()} double=${this.double()}`);
    });
  }

  increment() {
    this.count.update((c) => c + 1);
  }
}

Playwright で「+1」を3回クリックした結果(Angular 22.2.0):

タイミング count double label
初期表示 0 0 3未満
+1 を3回クリック後 3 6 3以上

ブラウザのコンソールには次の順でログが出ました。

[effect] count=0 double=0
[effect] count=1 double=2
[effect] count=2 double=4
[effect] count=3 double=6

ポイントは次の3つです。

  • シグナルは「関数として呼ぶ」と値が読める(count())。置き換えは set、今の値から作るなら update です。
  • computed は依存するシグナルが変わったときだけ再計算 され、それ以外はキャッシュを返します。
  • effect は最初に1回実行され、その後は読んだシグナルが変わるたびに実行 されます。effect はコンストラクタなど「注入コンテキスト」の中で作ります。

よくあるハマり例1:配列に push しても画面が変わらない

シグナルは「新しい値が前の値と同じかどうか」を Object.is で比べます。 配列やオブジェクトを直接書き換えて同じ参照を set すると、「変化なし」と判定されます。

// src/app/signals/pitfalls.ts(抜粋)
import { Component, computed, signal } from '@angular/core';

interface Item { id: number; name: string }

@Component({
  selector: 'app-pitfalls',
  template: `
    <p>件数(push): <b class="bad-count">{{ badCount() }}</b></p>
    <button class="bad-add" (click)="addByPush()">push で追加</button>
    <p>件数(update): <b class="good-count">{{ goodCount() }}</b></p>
    <button class="good-add" (click)="addByUpdate()">update で追加</button>
  `,
})
export class Pitfalls {
  // 直す前:配列を直接変更(mutate)
  private readonly badItems = signal<Item[]>([{ id: 1, name: 'ペン' }]);
  protected readonly badCount = computed(() => this.badItems().length);
  addByPush() {
    const list = this.badItems();
    list.push({ id: list.length + 1, name: 'ノート' });
    this.badItems.set(list); // 同じ参照なので「変化なし」と判定される
  }

  // 直した後:新しい配列を作る
  private readonly goodItems = signal<Item[]>([{ id: 1, name: 'ペン' }]);
  protected readonly goodCount = computed(() => this.goodItems().length);
  addByUpdate() {
    this.goodItems.update((list) => [...list, { id: list.length + 1, name: 'ノート' }]);
  }
}

実測結果(各ボタンを2回クリック):

書き方 期待する件数 画面の件数
push してから同じ配列を set 3 1(変わらない)
update(list => [...list, 新要素]) 3 3

エラーは出ず、画面が静かに変わらないだけなので気づきにくい挙動です。配列は [...list, x]、list.filter(...)、list.map(...)、オブジェクトは { ...obj, key: v } で新しい値を作る と確実に反映されます。

よくあるハマり例2:computed が更新されない

computed は「実行中に読んだシグナル」だけを依存として記録します。 ただのプロパティを読んでも、それが変わったことは伝わりません。

protected readonly price = signal(1000);
private rate = 1.1; // ただのプロパティ(シグナルではない)
protected readonly taxed = computed(() => Math.round(this.price() * this.rate));

changeRate() {
  this.rate = 1.2; // computed は再計算されない
}

実測結果:

操作 画面の税込価格 理由
初期表示 1100 1000 × 1.1
rate を 1.2 に変更 1100(変わらない) rate はシグナルではないので依存に入らない
その後 price を +100 1320 price の変化で再計算され、そのとき初めて rate=1.2 が使われる

直し方は、依存させたい値をすべてシグナルにすること です(rate = signal(1.1) にして this.rate() で読む)。

computed が更新されないときのチェックリストです。

確認すること よくある原因
依存する値はシグナルか ただのプロパティ、localStorage、Date.now() を読んでいる
早期 return で読み飛ばしていないか if (!flag()) return 0; の後のシグナルは、flag が false の間は依存に入らない
同じ参照を set していないか ハマり例1の mutate
untracked() の中で読んでいないか untracked の中は依存に入らない

よくあるハマり例3:effect で無限ループする

effect の中で「自分が読んだシグナル」に書き込むと、書き込み → 再実行 → また書き込み…とループします。

// 直す前(実行するとタブが応答しなくなります)
@Component({
  selector: 'app-loop-bad',
  template: `<p>bad searches: {{ searches() }}</p>`,
})
export class LoopBad {
  protected readonly searches = signal(0);
  constructor() {
    effect(() => {
      this.searches.set(this.searches() + 1); // 読んで、書く
    });
  }
}

このコンポーネントを Playwright で開いたところ、エラーメッセージは出ず、30秒待ってもページのメインスレッドが応答しない(page.evaluate が返ってこない)状態 になりました。コンソールに出たのは Angular is running in development mode. だけです。エラー文で気づけないので、「画面が固まったら effect の読み書きを疑う」 と覚えておくと切り分けが早くなります。

直し方は、依存にしたくない読み取りを untracked() で囲む ことです。

// src/app/signals/loop.ts
import { Component, effect, signal, untracked } from '@angular/core';

@Component({
  selector: 'app-loop',
  template: `
    <p>query: <b class="q">{{ query() }}</b> / 検索回数: <b class="searches">{{ searches() }}</b></p>
    <input class="qin" [value]="query()" (input)="query.set($any($event.target).value)" />
  `,
})
export class Loop {
  protected readonly query = signal('');
  protected readonly searches = signal(0);

  constructor() {
    effect(() => {
      const q = this.query(); // ← これだけを依存にしたい
      untracked(() => this.searches.update((n) => n + 1)); // 依存に含めない
      console.log(`[search] q="${q}"`);
    });
  }
}

実測結果: 入力欄に「ペン」と入力すると、表示は query: ペン / 検索回数: 2(初回実行の1回+入力の1回)になり、コンソールは [search] q="" → [search] q="ペン" の2行で止まりました。

そもそも「シグナルから別のシグナルを作る」なら effect ではなく computed か linkedSignal を使う のが基本です。effect は「DOM操作・ログ・保存・外部ライブラリへの反映」など、シグナルの外側に出す処理に使います。

linkedSignal:選択肢が変わったら選択をリセットする

linkedSignal は「元のシグナルに連動して値が決まるが、手動で set もできる」シグナル です。computed は読み取り専用なので、「カテゴリを変えたら選択中の商品を先頭に戻したい、でも手動で選び直せるようにもしたい」ときに使います。

// src/app/signals/linked.ts
import { Component, linkedSignal, signal } from '@angular/core';

@Component({
  selector: 'app-linked',
  template: `
    <button class="cat-stationery" (click)="category.set('stationery')">文具</button>
    <button class="cat-food" (click)="category.set('food')">食品</button>
    <p>選択中: <b class="selected">{{ selected() }}</b></p>
    @for (o of options(); track o) {
      <button class="opt" (click)="selected.set(o)">{{ o }}</button>
    }
  `,
})
export class Linked {
  private readonly catalog: Record<string, string[]> = {
    stationery: ['ペン', 'ノート', '付箋'],
    food: ['お茶', 'クッキー'],
  };
  protected readonly category = signal<'stationery' | 'food'>('stationery');
  protected readonly options = linkedSignal(() => this.catalog[this.category()]);

  // 元(options)が変わったら、前の選択が残っていれば維持、なければ先頭
  protected readonly selected = linkedSignal<string[], string>({
    source: this.options,
    computation: (opts, previous) =>
      previous && opts.includes(previous.value) ? previous.value : opts[0],
  });
}

実測結果:

操作 選択中
初期表示 ペン
「付箋」をクリック(手動 set) 付箋
カテゴリを「食品」に変更 お茶(新しい選択肢の先頭にリセット)

source と computation を使う形では、型引数 linkedSignal<元の型, 値の型> を書かないと previous.value が unknown になり、次のエラーでビルドが止まりました。

TS2345: Argument of type 'unknown' is not assignable to parameter of type 'string'.

Angular 22.1 では、書き込み時の動きを変えられる set オプション も追加されています(例:一覧にない値を set したら一覧の先頭に追加する)。

computed と linkedSignal と effect の選び方

条件 選ぶAPI
他のシグナルから計算するだけ(書き換えない) computed
基本は連動、でもユーザー操作で書き換えたい linkedSignal
シグナルの外(DOM・ログ・保存・外部ライブラリ)に反映したい effect(描画後のDOM操作なら afterRenderEffect)
非同期で取得したい resource / httpResource

input() / model() / output():親子コンポーネントのやり取り

デコレーター @Input() / @Output() の代わりに、関数で入出力を宣言 します。input はシグナルなので、そのまま computed に使えます。

// src/app/signals/io.ts
import { Component, computed, input, model, output, signal } from '@angular/core';

@Component({
  selector: 'app-qty',
  template: `
    <span class="name">{{ name() }}</span>
    <button class="minus" (click)="qty.set(qty() - 1)" [disabled]="qty() <= min()">-</button>
    <b class="qty">{{ qty() }}</b>
    <button class="plus" (click)="qty.set(qty() + 1)">+</button>
    <button class="remove" (click)="removed.emit(name())">削除</button>
  `,
})
export class Qty {
  readonly name = input.required<string>(); // 必須の入力
  readonly min = input(0);                  // 既定値つき入力
  readonly qty = model(1);                  // 双方向バインド用
  readonly removed = output<string>();      // イベント
}

@Component({
  selector: 'app-cart',
  imports: [Qty],
  template: `
    @for (row of rows(); track row.name) {
      <p><app-qty [name]="row.name" [min]="1" [(qty)]="row.qty" (removed)="remove($event)" /></p>
    }
    <p>合計数量: <b class="total">{{ total() }}</b> / 最後に削除: <b class="last">{{ lastRemoved() }}</b></p>
  `,
})
export class Cart {
  protected readonly rows = signal([
    { name: 'ペン', qty: signal(2) },
    { name: 'ノート', qty: signal(1) },
  ]);
  protected readonly total = computed(() => this.rows().reduce((s, r) => s + r.qty(), 0));
  protected readonly lastRemoved = signal('-');
  remove(name: string) {
    this.rows.update((rs) => rs.filter((r) => r.name !== name));
    this.lastRemoved.set(name);
  }
}

実測結果:

操作 合計数量 備考
初期表示 3 ペン2+ノート1
ペンの「+」 4 子の model が親の signal を直接更新
ノートの「-」 4 qty <= min なのでボタンが disabled(Playwright で isDisabled() = true)
ペンの「削除」 1 output で親に通知、最後に削除=ペン

[(qty)]="row.qty" のように、model() には WritableSignal をそのまま双方向バインドできます。

旧来の書き方 シグナルの書き方 移行コマンド
@Input() name!: string name = input.required<string>() ng g @angular/core:signal-input-migration
@Output() removed = new EventEmitter<string>() removed = output<string>() ng g @angular/core:output-migration
@ViewChild('x') x!: ElementRef x = viewChild.required<ElementRef>('x') ng g @angular/core:signal-queries-migration

viewChild / viewChildren:シグナルクエリ

// src/app/signals/query.ts
import { Component, ElementRef, afterNextRender, signal, viewChild, viewChildren } from '@angular/core';

@Component({
  selector: 'app-query',
  template: `
    <input #search class="search" placeholder="検索" />
    @for (t of tags; track t) { <span #tag class="tag">{{ t }}</span> }
    <p>タグ数: <b class="tag-count">{{ tagEls().length }}</b></p>
    <p>フォーカス済み: <b class="focused">{{ focused() }}</b></p>
  `,
})
export class Query {
  protected readonly tags = ['在庫', '備品', 'タスク'];
  private readonly search = viewChild.required<ElementRef<HTMLInputElement>>('search');
  protected readonly tagEls = viewChildren<ElementRef<HTMLSpanElement>>('tag');
  protected readonly focused = signal(false);

  constructor() {
    afterNextRender(() => {
      this.search().nativeElement.focus();
      this.focused.set(document.activeElement === this.search().nativeElement);
    });
  }
}

実測結果: タグ数 3、フォーカス済み true。viewChild の結果もシグナルなので、ngAfterViewInit を待たずに computed や effect の中で使えます。

resource:非同期データをシグナルで扱う(v22で安定版)

resource / httpResource / rxResource は Angular 22 で安定版 になりました。詳しい使い方は別記事に譲り、ここでは最小の形だけ確認します。

// src/app/signals/res.ts
import { Component, resource, signal } from '@angular/core';

function fetchStock(item: string): Promise<number> {
  // 疑似API:300ms後に在庫数を返す
  return new Promise((resolve) => setTimeout(() => resolve(item.length * 10), 300));
}

@Component({
  selector: 'app-res',
  template: `
    <button class="res-pen" (click)="item.set('ペン')">ペン</button>
    <button class="res-note" (click)="item.set('ノートブック')">ノートブック</button>
    <p>status: <b class="res-status">{{ stock.status() }}</b></p>
    @if (stock.hasValue()) { <p>在庫: <b class="res-value">{{ stock.value() }}</b></p> }
  `,
})
export class Res {
  protected readonly item = signal('ペン');
  protected readonly stock = resource({
    params: () => ({ item: this.item() }),
    loader: ({ params }) => fetchStock(params.item),
  });
}

実測結果:

タイミング status 在庫の表示
読み込み完了 resolved 20
「ノートブック」クリック直後 loading 非表示(hasValue() が false)
約0.5秒後 resolved 60

params が参照するシグナル(item)が変わると、自動で loader が再実行されます。 別の resource の結果を待つ chain() も v22 で追加されました。

v22 で OnPush が既定に:シグナルで書くべき理由

Angular 22 では、changeDetection を書かないコンポーネントは OnPush として動きます。 これまでの既定だった ChangeDetectionStrategy.Default は ChangeDetectionStrategy.Eager という名前になりました。違いを実際に確かめます。

// src/app/signals/onpush.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

// シグナルではない、ただのオブジェクト
export const store = { count: 0 };
export const sigStore = signal(0);

@Component({
  selector: 'app-child-default',
  // changeDetection を書かない → v22 では OnPush
  template: `<p>既定(OnPush): <b class="child-default">{{ store.count }}</b> / signal: <b class="child-default-sig">{{ sigStore() }}</b></p>`,
})
export class ChildDefault {
  protected readonly store = store;
  protected readonly sigStore = sigStore;
}

@Component({
  selector: 'app-child-eager',
  changeDetection: ChangeDetectionStrategy.Eager, // v21 までの既定(旧 Default)
  template: `<p>Eager: <b class="child-eager">{{ store.count }}</b></p>`,
})
export class ChildEager {
  protected readonly store = store;
}

@Component({
  selector: 'app-onpush',
  imports: [ChildDefault, ChildEager],
  template: `
    <button class="bump" (click)="bump()">store.count++(ただのオブジェクト)</button>
    <button class="bump-sig" (click)="bumpSig()">sigStore +1(シグナル)</button>
    <app-child-default />
    <app-child-eager />
  `,
})
export class OnPushDemo {
  bump() { store.count++; }
  bumpSig() { sigStore.update((v) => v + 1); }
}

実測結果:

操作 既定(OnPush)の子 store.count 既定の子 signal Eager の子 store.count
「store.count++」を2回 0(変わらない) 0 2
続けて「sigStore +1」 2 1 2

OnPush の子は、ただのオブジェクトが書き換わっても再描画されません。 子のテンプレートが読んでいるシグナル(sigStore)が変わったときに初めて再描画され、そのときに最新の store.count も表示されました。v22 で画面が更新されないときは、状態をシグナルにする(またはそのコンポーネントを Eager にする) と解決します。既存プロジェクトを ng update で v22 に上げた場合は、動きを変えないよう全コンポーネントに ChangeDetectionStrategy.Eager が自動で付きます。

よくある質問

Q. シグナルと RxJS(Observable)はどちらを使えばよいですか? A. 画面に表示する「今の状態」はシグナル、時間の流れを扱うイベント(連続入力の間引き、WebSocket など)は RxJS が向いています。toSignal() と toObservable() で相互に変換できるので、併用して問題ありません。

Q. effect の中でシグナルに書き込んでもよいですか? A. 書き込み自体は可能ですが、自分が読んだシグナルに書き込むと無限ループ になります。多くは computed か linkedSignal で書き直せます。書き込みが必要なら読み取りを untracked() で囲みます。

Q. 既存の @Input() をすべて input() に書き換える必要がありますか? A. 必須ではありません。@Input() は v22 でも動きます。 ただし OnPush が既定になった v22 ではシグナルのほうが更新の仕組みと相性がよいため、ng g @angular/core:signal-input-migration で段階的に移行するのがおすすめです。

Q. signal の値にオブジェクトを入れたとき、一部だけ変えるには? A. user.update(u => ({ ...u, name: '新しい名前' })) のように スプレッド構文で新しいオブジェクトを作ります。 v22 ではテンプレートの中でもスプレッド構文とアロー関数が使えるようになりました。

まとめ

  • signal で値を持ち、computed で計算し、effect は外の世界への反映だけに使う のが基本の型です。
  • 配列やオブジェクトは mutate せず、新しい値を作って set / update する(push すると件数が変わらないことを実測で確認)。
  • computed はシグナルだけを依存として追跡 します。ただのプロパティは再計算のきっかけになりません。
  • effect で自分が読んだシグナルに書き込むと、エラーなしで画面が固まります。 untracked() か computed で解決します。
  • linkedSignal は「連動するけど手動で書き換えられる」値、input / model / output / viewChild もすべてシグナルとして扱えます。
  • v22 は OnPush が既定。 状態をシグナルで持てば、画面の更新が確実になります。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular Signals使い方|v22対応の全API早見表と実行結果つきコード」

株式会社RJCでは、Angular の新しいバージョンが出るたびに実際に手を動かして挙動を確かめ、その結果をチームで共有しています。こうした知識を共有しながら、日々技術を磨いています。

株式会社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で一緒に開発しながら、 成長を楽しみませんか?