Angular 18 Signals完全ガイド:新しいリアクティブAPIとZoneless変更検知

Angular 18のSignals、Zoneless変更検知、signal-based APIを解説。input()、model()、viewChild()の実践的な使い方をコード例付きで紹介します。

Angular 18のSignalsとモダンなリアクティビティの解説図

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な未来への準備です。

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

book-card.component.tstypescript
// 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のリアクティビティが加わります。

book-list.component.tstypescript
// 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を作成し、変更を親コンポーネントへ自動的に伝播します。

search-input.component.tstypescript
// 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());
    }
  }
}

親コンポーネントでは、バナナインボックス構文[()]を使って双方向バインディングを実現します。

app.component.tstypescript
// 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)
    );
  });
}
model()とinput()の使い分け

読み取り専用のデータ(親から子への一方向)にはinput()を使用します。子コンポーネントが値を変更する必要がある場合(双方向)はmodel()を使用します。

viewChild()とcontentChild()によるSignalクエリ

viewChild()viewChildren()contentChild()contentChildren()関数は、対応するデコレータを置き換えます。Signalを返すため、ngAfterViewInitのようなライフサイクルフックが不要になります。

form-container.component.tstypescript
// 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()が同様に機能します。

card.component.tstypescript
// 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へのモンキーパッチが不要になることでパフォーマンスが向上します。

main.tstypescript
// 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による新しい値の受信、コンポーネントのアタッチ/デタッチ。

zoneless-counter.component.tstypescript
// 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);
    }
  }
}
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: `
    <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);
  }
}
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: `
    <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を最大限に活用するための主要な推奨事項を紹介します。

best-practices.component.tstypescript
// 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バージョンに向けた準備です。この移行は、長期的な保守性とパフォーマンスのための賢明な投資と言えます。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#angular 18
#angular signals
#zoneless
#signal inputs
#reactivity

共有

関連記事