Dev Study
← 解説「@Input / @Output / @ViewChild — デコレータ時代の部品API」に戻る

サンプルコードで身につける: @Input / @Output / @ViewChild — デコレータ時代の部品API

解説で学んだ概念を、5つの具体例で確認します。素朴な例から実務寄りの例へと進みます。

1新旧対訳表 — コンポーネントAPI一覧

デコレータAPIと関数APIの対応を一覧にします。テンプレート側の書き方([]や())は新旧で同じなので、変わるのはクラス側だけです。

// ── クラス側の対訳(テンプレート側は新旧共通)──
//
// 旧(プロパティ)                  新(シグナル)
// @Input() name = '';              name = input('');
// @Input({ required: true })       input.required<number>()
// @Output() saved =                saved = output<string>();
//   new EventEmitter<string>();
// @ViewChild('el') el;             el = viewChild('el');
// @ContentChild(Cmp) c;            c = contentChild(Cmp);
//
// 読み方の切り替え:
//   旧 → this.name(そのまま)、変更検知は ngOnChanges
//   新 → this.name()(シグナル)、変更検知は computed / effect

2ngOnChanges — 入力の変化を受け止める旧作法

デコレータ版の@Input()はシグナルではないので、値の変化にcomputedで反応できません。代わりにngOnChangesフックがSimpleChangesという変更履歴を受け取ります。旧コードの頻出パターンです。

import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';

@Component({ selector: 'app-user-detail', template: '...' })
export class UserDetailComponent implements OnChanges {
  @Input() userId = 0;
  user?: User;

  // 入力が変わるたびに呼ばれる。changes には変更前後の値が入る
  ngOnChanges(changes: SimpleChanges) {
    if (changes['userId']) {
      console.log(
        changes['userId'].previousValue, // 変更前
        changes['userId'].currentValue,  // 変更後
      );
      this.loadUser(this.userId); // IDが変わったら取得し直す
    }
  }
}
// 現代版: userId = input(0) にして
//   user = resource({ params: () => ({ id: this.userId() }), ... })

3EventEmitter の正体は Observable

EventEmitterはRxJSのSubjectを継承しているため、emitで流した値をpipeで加工したりsubscribeしたりできます。凝った旧コードではこの性質を使った書き方に出会うことがあります。

import { Component, Output, EventEmitter } from '@angular/core';
import { debounceTime } from 'rxjs';

@Component({
  selector: 'app-search-box',
  template: '<input (input)="onInput($any($event.target).value)">',
})
export class SearchBoxComponent {
  @Output() search = new EventEmitter<string>();

  constructor() {
    // EventEmitter は Observable でもあるので pipe できる。
    // こういう「自分で自分を購読する」旧コードに出会うことがある
    this.search.pipe(debounceTime(300)).subscribe((q) => {
      console.log('検索実行:', q);
    });
  }

  onInput(value: string) {
    this.search.emit(value);
  }
}

4@ViewChild の時差と static オプション

デコレータ版のViewChildは「いつから値が入っているか」という時差の罠が有名でした。ngAfterViewInitまで待つのが基本形です。viewChild()シグナルがこの悩みをどう消したかも対比します。

import {
  Component, ViewChild, ElementRef, AfterViewInit,
} from '@angular/core';

@Component({
  selector: 'app-focus-input',
  template: '<input #nameInput>',
})
export class FocusInputComponent implements AfterViewInit {
  @ViewChild('nameInput') nameInput?: ElementRef<HTMLInputElement>;

  constructor() {
    // ここではまだ undefined!(ビューが未構築)
  }

  ngAfterViewInit() {
    // ビュー構築後のこのフックでやっと使える
    this.nameInput?.nativeElement.focus();
  }
}
// 現代版: nameInput = viewChild<ElementRef>('nameInput') にして
// effect(() => this.nameInput()?.nativeElement.focus());
// → シグナルなので「値が入ったら動く」が自然に書ける

5混在コンポーネントを読んでみる

移行途中のコードでは新旧APIが1つのクラスに同居します。実際にありそうな混在コンポーネントで、どの行がどちらの世代かを判定する練習をしましょう。目印はデコレータの有無です。

@Component({ selector: 'app-order-row', template: '...' })
export class OrderRowComponent {
  // 旧: デコレータ → ただのプロパティ。this.order で参照
  @Input() order!: Order;

  // 新: 関数 → シグナル。this.highlighted() で参照
  highlighted = input(false);

  // 旧: EventEmitter
  @Output() removed = new EventEmitter<number>();

  // 新: output()
  selected = output<number>();

  // 混在時の注意: computed から読めるのはシグナルだけ。
  // this.order を computed に入れても変化に追従しない
  label = computed(() =>
    this.highlighted() ? '★ 注目の注文' : '注文',
  );
}