Angular 18 Signals完全ガイド:新しいリアクティブAPIとZoneless変更検知
Angular 18のSignals、Zoneless変更検知、signal-based APIを解説。input()、model()、viewChild()の実践的な使い方をコード例付きで紹介します。

Angular 18は、Signalsの安定化によってフレームワークの進化における転換点となりました。この新しいリアクティブプリミティブは、Angularコンポーネントの構築方法を根本的に変え、従来のデコレータに代わるモダンな選択肢を提供しつつ、Zone.jsフリーの変更検知への道を開きます。
Angular 18のsignal-based API:input()、model()、viewChild()、そしてより軽量で高性能なアプリケーションを実現するZoneless設定について解説します。
Angular 18におけるSignalsの基本
Signalsは、Angularにおけるリアクティビティへの新しいアプローチです。Zone.jsの変更検知に依存する@Input()のようなクラシックなデコレータとは異なり、Signalsはきめ細かく明示的なリアクティビティを提供します。各Signalは値をカプセル化し、その値が変更されたときにコンシューマーへ自動的に通知します。
このアプローチには複数の利点があります。ターゲットを絞った更新によるパフォーマンス向上、computed()やeffect()関数とのネイティブ統合、そしてAngularのZonelessな未来への準備です。
// Demonstration of fundamental Signal concepts
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div class="counter-container">
<h2>Counter: {{ count() }}</h2>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">+1</button>
<button (click)="decrement()">-1</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
// Writable signal - value can be modified
count = signal(0);
// Computed signal - automatically derived from count
// Only recalculates when count changes
doubleCount = computed(() => this.count() * 2);
constructor() {
// Effect - executed on every count change
// Useful for side effects (logs, API calls, etc.)
effect(() => {
console.log(`New counter value: ${this.count()}`);
});
}
increment() {
// update() allows modification based on previous value
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
// set() directly replaces the value
this.count.set(0);
}
}Signalsはリアクティブなコンテナとして機能します。signal()は書き込み可能なSignalを作成し、computed()は派生した計算値を生成し、effect()は変更に応じたアクションの実行を可能にします。
input()によるSignal Inputs
input()関数は、従来の@Input()デコレータを置き換えます。読み取り専用のInputSignalを返すため、データが親から子へ一方向に流れ、誤って変更されることがありません。
// Component using signal inputs
import { Component, input, computed } from '@angular/core';
interface Book {
id: string;
title: string;
author: string;
price: number;
discountPercent?: number;
}
@Component({
selector: 'app-book-card',
standalone: true,
template: `
<article class="book-card">
<h3>{{ book().title }}</h3>
<p class="author">By {{ book().author }}</p>
@if (hasDiscount()) {
<p class="price">
<span class="original">\${{ book().price }}</span>
<span class="discounted">\${{ discountedPrice() }}</span>
</p>
} @else {
<p class="price">\${{ book().price }}</p>
}
@if (featured()) {
<span class="badge">Featured</span>
}
</article>
`
})
export class BookCardComponent {
// Required input - template won't compile without this prop
book = input.required<Book>();
// Optional input with default value
featured = input(false);
// Computed based on input - automatically recalculated
hasDiscount = computed(() => {
const discount = this.book().discountPercent;
return discount !== undefined && discount > 0;
});
// Discounted price calculation
discountedPrice = computed(() => {
const { price, discountPercent } = this.book();
if (!discountPercent) return price;
return (price * (100 - discountPercent) / 100).toFixed(2);
});
}親テンプレートでの使用方法は従来と似ていますが、型安全性とSignalのリアクティビティが加わります。
// Parent component using book-card
import { Component, signal } from '@angular/core';
import { BookCardComponent } from './book-card.component';
@Component({
selector: 'app-book-list',
standalone: true,
imports: [BookCardComponent],
template: `
<div class="book-grid">
@for (book of books(); track book.id) {
<app-book-card
[book]="book"
[featured]="book.id === featuredBookId()"
/>
}
</div>
`
})
export class BookListComponent {
books = signal<Book[]>([
{ id: '1', title: 'Clean Code', author: 'Robert C. Martin', price: 35 },
{ id: '2', title: 'The Pragmatic Programmer', author: 'David Thomas', price: 42, discountPercent: 15 }
]);
featuredBookId = signal('1');
}@Input()との最大の違いは、signal inputsが読み取り専用である点です。子コンポーネントからthis.book.set()を呼び出すことはできず、単方向データフローが強化されます。
model()による双方向バインディング
双方向の同期が必要なケースに対応するため、Angular 18はmodel()を導入しました。この関数は書き込み可能なSignalを作成し、変更を親コンポーネントへ自動的に伝播します。
// Component with bidirectional binding via model()
import { Component, model, output, computed } from '@angular/core';
@Component({
selector: 'app-search-input',
standalone: true,
template: `
<div class="search-container">
<input
type="text"
[value]="query()"
(input)="onInput($event)"
[placeholder]="placeholder()"
class="search-input"
/>
@if (query().length > 0) {
<button (click)="clear()" class="clear-btn">×</button>
}
<span class="char-count">{{ charCount() }} characters</span>
</div>
`
})
export class SearchInputComponent {
// model() creates a bidirectional Signal
// Modifications propagate to parent
query = model('');
// Classic input for configuration
placeholder = model('Search...');
// Output for additional events
searchSubmitted = output<string>();
// Computed based on model
charCount = computed(() => this.query().length);
onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
// Update model - propagates to parent
this.query.set(value);
}
clear() {
this.query.set('');
}
submit() {
if (this.query().length > 0) {
this.searchSubmitted.emit(this.query());
}
}
}親コンポーネントでは、バナナインボックス構文[()]を使って双方向バインディングを実現します。
// Using two-way binding with model()
import { Component, signal, effect } from '@angular/core';
import { SearchInputComponent } from './search-input.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [SearchInputComponent],
template: `
<div class="app-container">
<app-search-input [(query)]="searchTerm" />
<p>Current search: {{ searchTerm() }}</p>
<div class="results">
@for (result of filteredResults(); track result.id) {
<div class="result-item">{{ result.name }}</div>
}
</div>
</div>
`
})
export class AppComponent {
// Local signal synchronized with child component
searchTerm = signal('');
results = signal([
{ id: 1, name: 'Angular 18' },
{ id: 2, name: 'React 19' },
{ id: 3, name: 'Vue 3' }
]);
// Reactive filtering based on searchTerm
filteredResults = computed(() => {
const term = this.searchTerm().toLowerCase();
if (!term) return this.results();
return this.results().filter(r =>
r.name.toLowerCase().includes(term)
);
});
}読み取り専用のデータ(親から子への一方向)にはinput()を使用します。子コンポーネントが値を変更する必要がある場合(双方向)はmodel()を使用します。
viewChild()とcontentChild()によるSignalクエリ
viewChild()、viewChildren()、contentChild()、contentChildren()関数は、対応するデコレータを置き換えます。Signalを返すため、ngAfterViewInitのようなライフサイクルフックが不要になります。
// Demonstration of signal queries
import {
Component,
viewChild,
viewChildren,
ElementRef,
effect,
signal
} from '@angular/core';
import { FormFieldComponent } from './form-field.component';
@Component({
selector: 'app-form-container',
standalone: true,
imports: [FormFieldComponent],
template: `
<form #formElement (submit)="onSubmit($event)">
<input #firstInput type="text" placeholder="Name" />
<app-form-field label="Email" />
<app-form-field label="Phone" />
<div class="actions">
<button type="submit">Submit</button>
<button type="button" (click)="focusFirst()">Focus first field</button>
</div>
</form>
`
})
export class FormContainerComponent {
// viewChild returns Signal<ElementRef | undefined>
formElement = viewChild<ElementRef>('formElement');
// viewChild.required guarantees element exists
firstInput = viewChild.required<ElementRef<HTMLInputElement>>('firstInput');
// Query on a component - returns the component itself
firstFormField = viewChild(FormFieldComponent);
// viewChildren for multiple elements
allFormFields = viewChildren(FormFieldComponent);
constructor() {
// Effect replaces ngAfterViewInit for queries
effect(() => {
// Signal is automatically resolved
const input = this.firstInput();
console.log('First input available:', input.nativeElement);
});
// React to list changes
effect(() => {
const fields = this.allFormFields();
console.log(`${fields.length} form fields found`);
});
}
focusFirst() {
// Direct access via Signal
this.firstInput().nativeElement.focus();
}
onSubmit(event: Event) {
event.preventDefault();
// Access the form
const form = this.formElement();
if (form) {
console.log('Form submitted');
}
}
}コンテンツプロジェクションとそのアクセスには、contentChild()が同様に機能します。
// Using contentChild for projected content
import { Component, contentChild, contentChildren, TemplateRef } from '@angular/core';
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<header class="card-header">
<ng-content select="[card-title]" />
</header>
<div class="card-body">
<ng-content />
</div>
@if (hasFooter()) {
<footer class="card-footer">
<ng-content select="[card-footer]" />
</footer>
}
</div>
`
})
export class CardComponent {
// Detect if footer was projected
footerContent = contentChild<ElementRef>('[card-footer]');
// Computed to check footer presence
hasFooter = computed(() => this.footerContent() !== undefined);
}Angularの面接対策はできていますか?
インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。
Zoneless変更検知
Angular 18は、実験的モードとしてZone.jsフリーの変更検知を導入しました。この機能により、バンドルサイズが約13KB削減され、ブラウザの非同期APIへのモンキーパッチが不要になることでパフォーマンスが向上します。
// Configuring the application in zoneless mode
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
// Enable experimental zoneless detection
provideExperimentalZonelessChangeDetection()
]
});angular.jsonの設定も更新し、Zone.jsを除外する必要があります。
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": []
}
}
}
}
}
}Zonelessモードでは、以下のケースで変更検知が自動的にトリガーされます:Signalの更新、markForCheck()の呼び出し、AsyncPipeによる新しい値の受信、コンポーネントのアタッチ/デタッチ。
// Component optimized for zoneless mode
import {
Component,
signal,
ChangeDetectionStrategy,
inject
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-zoneless-counter',
standalone: true,
// OnPush recommended for zoneless
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="counter">
<p>Counter: {{ count() }}</p>
<button (click)="increment()">Increment</button>
@if (loading()) {
<p>Loading...</p>
}
@if (data()) {
<pre>{{ data() | json }}</pre>
}
</div>
`
})
export class ZonelessCounterComponent {
private http = inject(HttpClient);
count = signal(0);
loading = signal(false);
data = signal<any>(null);
increment() {
// Signal update triggers detection
this.count.update(c => c + 1);
}
async fetchData() {
this.loading.set(true);
try {
// Signals guarantee view updates
const response = await fetch('/api/data');
const json = await response.json();
this.data.set(json);
} finally {
this.loading.set(false);
}
}
}ChangeDetectionStrategy.OnPushとSignalsを使用するコンポーネントは、一般的にZonelessモードと互換性があります。Signalではないプロパティを直接変更することは避けてください。
既存コンポーネントの移行
signal-based APIへの移行は段階的に進めることができます。従来のコンポーネントをリファクタリングする例を示します。
// BEFORE: Component with classic decorators
// user-profile-legacy.component.ts
import { Component, Input, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-user-profile-legacy',
template: `
<div #container>
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
`
})
export class UserProfileLegacyComponent implements AfterViewInit {
@Input() user!: { name: string; email: string };
@ViewChild('container') container!: ElementRef;
ngAfterViewInit() {
console.log('Container ready:', this.container.nativeElement);
}
}// AFTER: Component migrated to Signals
// user-profile.component.ts
import { Component, input, viewChild, ElementRef, effect } from '@angular/core';
interface User {
name: string;
email: string;
}
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
<div #container>
<h2>{{ user().name }}</h2>
<p>{{ user().email }}</p>
</div>
`
})
export class UserProfileComponent {
// input.required replaces @Input() with !
user = input.required<User>();
// viewChild.required replaces @ViewChild with !
container = viewChild.required<ElementRef>('container');
constructor() {
// effect replaces ngAfterViewInit for queries
effect(() => {
console.log('Container ready:', this.container().nativeElement);
});
}
}この移行の利点は、より厳密な型付け、自動的なリアクティビティ、ボイラープレートコードの削減、そしてZonelessモードとの互換性です。
Signalsのベストプラクティス
Angular 18でSignalsを最大限に活用するための主要な推奨事項を紹介します。
// Example of best practices with Signals
import {
Component,
signal,
computed,
effect,
untracked,
ChangeDetectionStrategy
} from '@angular/core';
interface Product {
id: string;
name: string;
price: number;
quantity: number;
}
@Component({
selector: 'app-cart',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="cart">
<h2>Cart ({{ itemCount() }} items)</h2>
@for (item of items(); track item.id) {
<div class="cart-item">
<span>{{ item.name }}</span>
<span>{{ item.quantity }} × \${{ item.price }}</span>
<button (click)="removeItem(item.id)">Remove</button>
</div>
}
<div class="cart-total">
<strong>Total: \${{ total() }}</strong>
</div>
</div>
`
})
export class CartComponent {
// Signal for mutable data
items = signal<Product[]>([]);
// Computed for derived values - avoids unnecessary recalculations
itemCount = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
constructor() {
// Effect for side effects (analytics, persistence)
effect(() => {
const currentItems = this.items();
// untracked avoids creating a dependency
untracked(() => {
localStorage.setItem('cart', JSON.stringify(currentItems));
});
});
}
addItem(product: Product) {
// update() for modifications based on previous state
this.items.update(current => {
const existing = current.find(i => i.id === product.id);
if (existing) {
return current.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...current, { ...product, quantity: 1 }];
});
}
removeItem(id: string) {
this.items.update(current => current.filter(i => i.id !== id));
}
}押さえておくべきポイントは以下の通りです。
- テンプレート内で再計算するのではなく、派生値には
computed()を使用する - 新しい値が前の値に依存する場合は、
set()よりもupdate()を優先する - effect内で循環依存を避けるために
untracked()を使用する - レンダリングを最適化するために
@forループでは必ずtrackを指定する
まとめ
Angular 18は、Signalsを通じてZone.jsフリーの未来の基盤を築きました。主なポイントは以下の通りです。
- input() は
@Input()を置き換え、より厳密な型付けと読み取り専用アクセスを保証する - model() は親子間のリアクティブな双方向バインディングを実現する
- viewChild() と contentChild() はライフサイクルフックの必要性を排除する
- Zoneless はバンドルサイズを削減し、パフォーマンスを向上させる
- computed() と effect() がリアクティブエコシステムを完成させる
- コンポーネント単位での段階的な移行が可能
Signalsの採用は、Zonelessモードが標準となる将来のAngularバージョンに向けた準備です。この移行は、長期的な保守性とパフォーマンスのための賢明な投資と言えます。
今すぐ練習を始めましょう!
面接シミュレーターと技術テストで知識をテストしましょう。
タグ
共有
