- Angular
- TypeScript
- 技術 💻
Angular Signals使い方|v22対応の全API早見表と実行結果つきコード
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期連続の黒字決算。