Angular Signals完全ガイド:signal(), computed(), effect()で始めるリアクティブプログラミング


Angular Signalsは、Angular 16で導入された新しいリアクティブプログラミングモデルです。この記事では、Signalsの基本から実践的な活用法、RxJSとの共存方法まで詳しく解説します。

Angular Signalsとは

Signalsは、Angular独自のリアクティブプリミティブで、状態管理とChange Detectionを劇的にシンプルかつ高速にします。

従来の問題点

// 従来のAngular(Zone.js依存)
export class CounterComponent {
  count = 0;

  increment() {
    this.count++;  // Zone.jsが変更を検知してChange Detection
  }
}

この方法の問題点:

  • パフォーマンス - コンポーネントツリー全体をチェック
  • 予測不可能 - いつChange Detectionが走るか不明確
  • デバッグ困難 - Zone.jsのマジックが見えにくい

Signalsによる解決

// Signals(Angular 16+)
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <p>Count: {{ count() }}</p>
    <button (click)="increment()">+1</button>
  `
})
export class CounterComponent {
  count = signal(0);  // Signal作成

  increment() {
    this.count.update(n => n + 1);  // 自動的にこのコンポーネントだけ更新
  }
}

基本的なSignal

signal() - 書き込み可能なSignal

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-user-profile',
  template: `
    <h1>{{ username() }}</h1>
    <p>Age: {{ age() }}</p>
    <button (click)="incrementAge()">Happy Birthday!</button>
  `
})
export class UserProfileComponent {
  // プリミティブ型
  username = signal('Alice');
  age = signal(25);

  // オブジェクト型
  user = signal({
    name: 'Alice',
    email: 'alice@example.com'
  });

  // 値の読み取り(関数呼び出し)
  getUsername() {
    return this.username();  // "Alice"
  }

  // 値の更新(set)
  setUsername(name: string) {
    this.username.set(name);
  }

  // 値の更新(update)
  incrementAge() {
    this.age.update(current => current + 1);
  }

  // オブジェクトの更新
  updateEmail(email: string) {
    this.user.update(current => ({
      ...current,
      email
    }));
  }
}

set() vs update()

// set() - 新しい値で完全に置き換える
count.set(10);

// update() - 現在の値を元に更新
count.update(n => n + 1);

// オブジェクトの場合
user.set({ name: 'Bob', email: 'bob@example.com' });
user.update(u => ({ ...u, name: 'Bob' }));

computed() - 派生Signal

computed()は、他のSignalから自動的に計算される読み取り専用のSignalです。

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-shopping-cart',
  template: `
    <h2>Shopping Cart</h2>
    <p>Items: {{ itemCount() }}</p>
    <p>Total: ${{ total() }}</p>
    <p>Tax: ${{ tax() }}</p>
    <p>Grand Total: ${{ grandTotal() }}</p>
  `
})
export class ShoppingCartComponent {
  items = signal([
    { name: 'Apple', price: 1.2, quantity: 3 },
    { name: 'Banana', price: 0.8, quantity: 5 },
  ]);

  // アイテム数を自動計算
  itemCount = computed(() => {
    return this.items().reduce((sum, item) => sum + item.quantity, 0);
  });

  // 合計金額を自動計算
  total = computed(() => {
    return this.items().reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  });

  // 税金を自動計算(totalが変わると自動的に再計算)
  tax = computed(() => this.total() * 0.1);

  // 総計を自動計算(totalとtaxが変わると自動的に再計算)
  grandTotal = computed(() => this.total() + this.tax());

  addItem(item: Item) {
    this.items.update(items => [...items, item]);
    // itemCount, total, tax, grandTotal すべて自動更新
  }
}

computed()の特徴

  • 自動追跡 - 依存するSignalを自動的に追跡
  • メモ化 - 依存が変わらない限り再計算しない
  • 遅延評価 - 実際に読み取られるまで計算しない
  • 読み取り専用 - 値を直接変更できない
// ネストしたcomputed
const firstName = signal('John');
const lastName = signal('Doe');

const fullName = computed(() => `${firstName()} ${lastName()}`);
const greeting = computed(() => `Hello, ${fullName()}!`);

console.log(greeting());  // "Hello, John Doe!"

firstName.set('Jane');
console.log(greeting());  // "Hello, Jane Doe!" (自動更新)

effect() - 副作用の実行

effect()は、Signalが変更されたときに自動的に実行される副作用です。

import { Component, signal, effect } from '@angular/core';

@Component({
  selector: 'app-theme-switcher',
  template: `
    <button (click)="toggleTheme()">
      Current: {{ theme() }}
    </button>
  `
})
export class ThemeSwitcherComponent {
  theme = signal<'light' | 'dark'>('light');

  constructor() {
    // themeが変わるたびに実行される
    effect(() => {
      const currentTheme = this.theme();
      console.log('Theme changed to:', currentTheme);

      // DOMを直接操作
      document.body.className = currentTheme;

      // LocalStorageに保存
      localStorage.setItem('theme', currentTheme);
    });

    // 初期化時にLocalStorageから読み込み
    const savedTheme = localStorage.getItem('theme') as 'light' | 'dark';
    if (savedTheme) {
      this.theme.set(savedTheme);
    }
  }

  toggleTheme() {
    this.theme.update(t => t === 'light' ? 'dark' : 'light');
  }
}

effect()のクリーンアップ

import { effect } from '@angular/core';

effect((onCleanup) => {
  const userId = this.userId();

  // WebSocket接続
  const ws = new WebSocket(`ws://api.example.com/users/${userId}`);

  ws.onmessage = (event) => {
    this.userData.set(JSON.parse(event.data));
  };

  // クリーンアップ(userIdが変わったときやコンポーネント破棄時)
  onCleanup(() => {
    ws.close();
    console.log('WebSocket closed');
  });
});

effect()の実行タイミング制御

import { effect } from '@angular/core';

// デフォルト(即座に実行)
effect(() => {
  console.log('Current count:', this.count());
});

// 最初は実行せず、変更時のみ実行
effect(() => {
  console.log('Count changed:', this.count());
}, { allowSignalWrites: false });

フォームとSignals

リアクティブフォームとの統合

import { Component, signal } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-user-form',
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <input formControlName="email" placeholder="Email">
      <input formControlName="password" type="password" placeholder="Password">

      <p *ngIf="isValid()">✓ Form is valid</p>
      <p *ngIf="!isValid()">✗ Form has errors</p>

      <button type="submit" [disabled]="!isValid()">Submit</button>
    </form>
  `
})
export class UserFormComponent {
  form: FormGroup;
  isValid = signal(false);

  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(8)]],
    });

    // フォームの状態をSignalに同期
    this.form.statusChanges.subscribe(status => {
      this.isValid.set(status === 'VALID');
    });
  }

  onSubmit() {
    if (this.isValid()) {
      console.log('Form data:', this.form.value);
    }
  }
}

カスタムフォームコントロール

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-custom-input',
  template: `
    <input
      [value]="value()"
      (input)="onInput($event)"
      [class.error]="hasError()"
    >
    <span *ngIf="hasError()" class="error-message">
      {{ errorMessage() }}
    </span>
  `
})
export class CustomInputComponent {
  value = signal('');
  validators = signal<Array<(value: string) => string | null>>([]);

  // エラーメッセージを自動計算
  errorMessage = computed(() => {
    const val = this.value();
    for (const validator of this.validators()) {
      const error = validator(val);
      if (error) return error;
    }
    return null;
  });

  hasError = computed(() => this.errorMessage() !== null);

  onInput(event: Event) {
    const input = event.target as HTMLInputElement;
    this.value.set(input.value);
  }

  // バリデーターを追加
  addValidator(validator: (value: string) => string | null) {
    this.validators.update(validators => [...validators, validator]);
  }
}

RxJSとの共存

SignalsとRxJSを組み合わせることで、両方の利点を活用できます。

SignalをObservableに変換

import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap } from 'rxjs/operators';

@Component({
  selector: 'app-search',
  template: `
    <input (input)="onSearch($event)" placeholder="Search...">
    <ul>
      <li *ngFor="let result of results()">{{ result }}</li>
    </ul>
  `
})
export class SearchComponent {
  searchTerm = signal('');
  results = signal<string[]>([]);

  constructor() {
    // SignalをObservableに変換
    const searchTerm$ = toObservable(this.searchTerm);

    searchTerm$
      .pipe(
        debounceTime(300),
        switchMap(term => this.searchApi(term))
      )
      .subscribe(results => {
        this.results.set(results);
      });
  }

  onSearch(event: Event) {
    const input = event.target as HTMLInputElement;
    this.searchTerm.set(input.value);
  }

  searchApi(term: string) {
    // APIコール(Observable)
    return this.http.get<string[]>(`/api/search?q=${term}`);
  }
}

ObservableをSignalに変換

import { Component, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({
  selector: 'app-timer',
  template: `<p>Elapsed: {{ elapsed() }} seconds</p>`
})
export class TimerComponent {
  // ObservableをSignalに変換
  elapsed = toSignal(interval(1000), { initialValue: 0 });
}

HTTPリクエストとSignals

import { Component, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

interface User {
  id: number;
  name: string;
  email: string;
}

@Component({
  selector: 'app-user-list',
  template: `
    <div *ngIf="loading()">Loading...</div>
    <div *ngIf="error()">Error: {{ error() }}</div>
    <ul *ngIf="users()">
      <li *ngFor="let user of users()">
        {{ user.name }} ({{ user.email }})
      </li>
    </ul>
  `
})
export class UserListComponent {
  loading = signal(true);
  error = signal<string | null>(null);
  users = signal<User[]>([]);

  constructor(private http: HttpClient) {
    this.loadUsers();
  }

  loadUsers() {
    this.http.get<User[]>('/api/users')
      .subscribe({
        next: (users) => {
          this.users.set(users);
          this.loading.set(false);
        },
        error: (err) => {
          this.error.set(err.message);
          this.loading.set(false);
        }
      });
  }
}

状態管理

シンプルなストア

// store.service.ts
import { Injectable, signal, computed } from '@angular/core';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

@Injectable({ providedIn: 'root' })
export class TodoStore {
  // プライベートなSignal(内部状態)
  private todos = signal<Todo[]>([]);
  private nextId = signal(1);

  // パブリックなcomputed(読み取り専用)
  allTodos = computed(() => this.todos());
  completedTodos = computed(() => this.todos().filter(t => t.completed));
  activeTodos = computed(() => this.todos().filter(t => !t.completed));
  todoCount = computed(() => this.todos().length);

  // アクション
  addTodo(text: string) {
    const id = this.nextId();
    this.todos.update(todos => [
      ...todos,
      { id, text, completed: false }
    ]);
    this.nextId.update(id => id + 1);
  }

  toggleTodo(id: number) {
    this.todos.update(todos =>
      todos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }

  removeTodo(id: number) {
    this.todos.update(todos => todos.filter(t => t.id !== id));
  }

  clearCompleted() {
    this.todos.update(todos => todos.filter(t => !t.completed));
  }
}

コンポーネントから利用

@Component({
  selector: 'app-todo-list',
  template: `
    <h2>Todos ({{ store.todoCount() }})</h2>

    <input #input (keyup.enter)="addTodo(input.value); input.value = ''">

    <ul>
      <li *ngFor="let todo of store.allTodos()">
        <input
          type="checkbox"
          [checked]="todo.completed"
          (change)="store.toggleTodo(todo.id)"
        >
        <span [class.completed]="todo.completed">{{ todo.text }}</span>
        <button (click)="store.removeTodo(todo.id)">Delete</button>
      </li>
    </ul>

    <p>Active: {{ store.activeTodos().length }}</p>
    <p>Completed: {{ store.completedTodos().length }}</p>
    <button (click)="store.clearCompleted()">Clear Completed</button>
  `
})
export class TodoListComponent {
  constructor(public store: TodoStore) {}

  addTodo(text: string) {
    if (text.trim()) {
      this.store.addTodo(text.trim());
    }
  }
}

パフォーマンス最適化

OnPush戦略との組み合わせ

import { Component, ChangeDetectionStrategy, signal } from '@angular/core';

@Component({
  selector: 'app-optimized',
  template: `<p>{{ data() }}</p>`,
  changeDetection: ChangeDetectionStrategy.OnPush  // 手動Change Detection
})
export class OptimizedComponent {
  data = signal('Initial');

  // SignalsはOnPushと完璧に連携
  // Signalが更新されると自動的にこのコンポーネントだけ更新
}

まとめ

Angular Signalsの主な利点をまとめます。

  • シンプル - Zone.jsなしでリアクティブに
  • 高速 - 必要な部分だけ更新
  • 型安全 - TypeScriptと完全統合
  • RxJS共存 - toObservable/toSignalで相互変換
  • デバッグ容易 - 明示的な依存関係

Angular 16以降の新規プロジェクトでは、Signalsを積極的に活用しましょう。従来のRxJSベースのコードも徐々にSignalsに移行することで、よりシンプルで保守しやすいコードベースを実現できます。