# Angular 18 Signals完全ガイド:新しいリアクティブAPIとZoneless変更検知 > Angular 18のSignals、Zoneless変更検知、signal-based APIを解説。input()、model()、viewChild()の実践的な使い方をコード例付きで紹介します。 - Published: 2026-01-18 - Updated: 2026-04-10 - Author: SharpSkill - Tags: angular 18, angular signals, zoneless, signal inputs, reactivity - Reading time: 12 min --- 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な未来への準備です。 ```typescript // signals-basics.component.ts // Demonstration of fundamental Signal concepts import { Component, signal, computed, effect } from '@angular/core'; @Component({ selector: 'app-counter', standalone: true, template: `

Counter: {{ count() }}

Double: {{ doubleCount() }}

` }) 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`を返すため、データが親から子へ一方向に流れ、誤って変更されることがありません。 ```typescript // book-card.component.ts // 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: `

{{ book().title }}

By {{ book().author }}

@if (hasDiscount()) {

\${{ book().price }} \${{ discountedPrice() }}

} @else {

\${{ book().price }}

} @if (featured()) { Featured }
` }) export class BookCardComponent { // Required input - template won't compile without this prop book = input.required(); // 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のリアクティビティが加わります。 ```typescript // book-list.component.ts // 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: `
@for (book of books(); track book.id) { }
` }) export class BookListComponent { books = signal([ { 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を作成し、変更を親コンポーネントへ自動的に伝播します。 ```typescript // search-input.component.ts // Component with bidirectional binding via model() import { Component, model, output, computed } from '@angular/core'; @Component({ selector: 'app-search-input', standalone: true, template: `
@if (query().length > 0) { } {{ charCount() }} characters
` }) 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(); // 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()); } } } ``` 親コンポーネントでは、バナナインボックス構文`[()]`を使って双方向バインディングを実現します。 ```typescript // app.component.ts // 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: `

Current search: {{ searchTerm() }}

@for (result of filteredResults(); track result.id) {
{{ result.name }}
}
` }) 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) ); }); } ``` > **model()とinput()の使い分け** > > 読み取り専用のデータ(親から子への一方向)には`input()`を使用します。子コンポーネントが値を変更する必要がある場合(双方向)は`model()`を使用します。 ## viewChild()とcontentChild()によるSignalクエリ `viewChild()`、`viewChildren()`、`contentChild()`、`contentChildren()`関数は、対応するデコレータを置き換えます。Signalを返すため、`ngAfterViewInit`のようなライフサイクルフックが不要になります。 ```typescript // form-container.component.ts // 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: `
` }) export class FormContainerComponent { // viewChild returns Signal formElement = viewChild('formElement'); // viewChild.required guarantees element exists firstInput = viewChild.required>('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()`が同様に機能します。 ```typescript // card.component.ts // Using contentChild for projected content import { Component, contentChild, contentChildren, TemplateRef } from '@angular/core'; @Component({ selector: 'app-card', standalone: true, template: `
@if (hasFooter()) {
}
` }) export class CardComponent { // Detect if footer was projected footerContent = contentChild('[card-footer]'); // Computed to check footer presence hasFooter = computed(() => this.footerContent() !== undefined); } ``` ## Zoneless変更検知 Angular 18は、実験的モードとしてZone.jsフリーの変更検知を導入しました。この機能により、バンドルサイズが約13KB削減され、ブラウザの非同期APIへのモンキーパッチが不要になることでパフォーマンスが向上します。 ```typescript // main.ts // 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を除外する必要があります。 ```json { "projects": { "my-app": { "architect": { "build": { "options": { "polyfills": [] } } } } } } ``` Zonelessモードでは、以下のケースで変更検知が自動的にトリガーされます:Signalの更新、`markForCheck()`の呼び出し、AsyncPipeによる新しい値の受信、コンポーネントのアタッチ/デタッチ。 ```typescript // zoneless-counter.component.ts // 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: `

Counter: {{ count() }}

@if (loading()) {

Loading...

} @if (data()) {
{{ data() | json }}
}
` }) export class ZonelessCounterComponent { private http = inject(HttpClient); count = signal(0); loading = signal(false); data = signal(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); } } } ``` > **Zonelessの互換性** > > `ChangeDetectionStrategy.OnPush`とSignalsを使用するコンポーネントは、一般的にZonelessモードと互換性があります。Signalではないプロパティを直接変更することは避けてください。 ## 既存コンポーネントの移行 signal-based APIへの移行は段階的に進めることができます。従来のコンポーネントをリファクタリングする例を示します。 ```typescript // 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: `

{{ user.name }}

{{ user.email }}

` }) export class UserProfileLegacyComponent implements AfterViewInit { @Input() user!: { name: string; email: string }; @ViewChild('container') container!: ElementRef; ngAfterViewInit() { console.log('Container ready:', this.container.nativeElement); } } ``` ```typescript // 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: `

{{ user().name }}

{{ user().email }}

` }) export class UserProfileComponent { // input.required replaces @Input() with ! user = input.required(); // viewChild.required replaces @ViewChild with ! container = viewChild.required('container'); constructor() { // effect replaces ngAfterViewInit for queries effect(() => { console.log('Container ready:', this.container().nativeElement); }); } } ``` この移行の利点は、より厳密な型付け、自動的なリアクティビティ、ボイラープレートコードの削減、そしてZonelessモードとの互換性です。 ## Signalsのベストプラクティス Angular 18でSignalsを最大限に活用するための主要な推奨事項を紹介します。 ```typescript // best-practices.component.ts // 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: `

Cart ({{ itemCount() }} items)

@for (item of items(); track item.id) {
{{ item.name }} {{ item.quantity }} × \${{ item.price }}
}
Total: \${{ total() }}
` }) export class CartComponent { // Signal for mutable data items = signal([]); // 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バージョンに向けた準備です。この移行は、長期的な保守性とパフォーマンスのための賢明な投資と言えます。 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ja/blog/angular/angular-18-signals-new-features