Angularの親子コンポーネント間のデータ受け渡しとは、親から子へは input()、子から親へは output()、親子の両方向は model() という3つの関数で値とイベントをつなぐ仕組みです。 以前からある @Input / @Output デコレータも引き続き使えますが、新しく書くコードでは関数形式が公式ドキュメントで推奨されています。

この記事では、新旧の書き方の対応表、親子2ファイルでそのまま動く完成コード、必須入力を渡し忘れたときの NG8008 などのエラー文と直し方、そして既存コードを自動で書き換える移行コマンドの実行結果と変換差分までを、すべて手元で動かした出力つきでまとめます。

確認環境:Ubuntu 24.04 / Node.js 24.21.0 / Angular CLI 22.2.1・Angular 22.2.1 / TypeScript 6.0.3。動作は ng build と、ng test(Vitest 5.0.3 + jsdom)で確かめています。

input・output・model の早見表

まずは「どの向きに何を渡したいか」から選べる一覧です。

やりたいこと 子コンポーネントの書き方 親テンプレートの書き方 子の中での読み書き
親 → 子に値を渡す(省略可) price = input(0); [price]="1200" price() で読む(書き込みは不可)
親 → 子に値を渡す(必須) name = input.required<string>(); [name]="itemName()" name() で読む
渡された値を変換して受け取る input(0, { transform: numberAttribute }) price="1200" 数値の 1200 として読める
属性を書くだけで true にする input(false, { transform: booleanAttribute }) featured featured() が true
外向きの名前を変える label = input('', { alias: 'caption' }) caption="..." label() で読む
子 → 親にイベントを伝える added = output<string>(); (added)="onAdded($event)" added.emit('ノート')
親 ⇔ 子で同じ値を共有する quantity = model(1); [(quantity)]="qty" quantity() で読み、set / update で書く
入力から別の値を作る total = computed(() => price() * quantity()) (不要) total()

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular input・output・modelの使い方|親子の受け渡し」

@Input/@Output と input()/output()/model() の新旧対応表

以前からあるデコレータ形式と、関数形式を1対1で並べます。どちらも同じテンプレート構文([x]、(x)、[(x)])で親から使えるので、親側のHTMLは基本的にそのままです。

用途 デコレータ形式 関数形式
省略可の入力 @Input() price = 0; price = input(0);
必須の入力 @Input({ required: true }) name!: string; name = input.required<string>();
変換つき入力 @Input({ transform: numberAttribute }) price = 0; price = input(0, { transform: numberAttribute });
別名つき入力 @Input('caption') label = ''; label = input('', { alias: 'caption' });
イベント @Output() added = new EventEmitter<string>(); added = output<string>();
双方向 @Input() quantity と @Output() quantityChange の2つ quantity = model(1); の1行
子での読み方 this.price(ただの値) this.price()(関数として呼ぶ)
テンプレートでの読み方 {{ price }} {{ price() }}
変化への反応 ngOnChanges や setter computed() / effect()

違いの中心は「値そのもの」ではなく「シグナル(値の入った箱)」を受け取る点です。シグナルは price() のように関数として呼ぶと中身が取り出せ、中身が変わると、それを使っている画面や computed() が自動で更新されます。

公式ドキュメント(Accepting data with input properties)には、新しいコードでは関数形式の input を推奨しつつ、デコレータ形式の @Input も引き続き完全にサポートされる、と書かれています。急いで全部を書き換える必要はなく、新しく作るコンポーネントから関数形式にしていく進め方で十分です。

親子2ファイルの完成コード(コピペで動く)

ng new demo で作ったプロジェクトに、子コンポーネント item-card.ts を追加し、app.ts を書き換えます。備品の注文カードを例に、input・output・model をすべて使います。

子コンポーネント:src/app/item-card.ts

import { Component, booleanAttribute, computed, input, model, numberAttribute, output } from '@angular/core';

@Component({
  selector: 'app-item-card',
  template: `
    <section [class.featured]="featured()">
      <h2>{{ label() || name() }}</h2>
      <p class="price">単価 {{ price() }} 円 × {{ quantity() }} 個 = {{ total() }} 円</p>
      <button type="button" class="minus" (click)="change(-1)">−</button>
      <button type="button" class="plus" (click)="change(1)">+</button>
      <button type="button" class="add" (click)="added.emit(name())">カートに入れる</button>
    </section>
  `,
})
export class ItemCard {
  // 親 → 子(必須)
  name = input.required<string>();
  // 親 → 子(省略可・初期値あり・文字列を数値に変換)
  price = input(0, { transform: numberAttribute });
  // 属性を書くだけで true になる
  featured = input(false, { transform: booleanAttribute });
  // 外向きの名前を caption にする
  label = input('', { alias: 'caption' });
  // 親 ⇔ 子(双方向)
  quantity = model(1);
  // 子 → 親(イベント)
  added = output<string>();

  total = computed(() => this.price() * this.quantity());

  change(diff: number) {
    this.quantity.update((q) => Math.max(1, q + diff));
  }
}

親コンポーネント:src/app/app.ts

import { Component, signal } from '@angular/core';
import { ItemCard } from './item-card';

@Component({
  selector: 'app-root',
  imports: [ItemCard],
  template: `
    <h1>備品の注文</h1>
    <app-item-card
      [name]="itemName()"
      price="1200"
      featured
      caption="おすすめ:ノート"
      [(quantity)]="qty"
      (added)="onAdded($event)"
    />
    <p class="parent">親が持っている数量: {{ qty() }}</p>
    <p class="log">{{ log() }}</p>
  `,
})
export class App {
  itemName = signal('ノート');
  qty = signal(2);
  log = signal('まだ追加されていません');

  onAdded(name: string) {
    this.log.set(`${name} を ${this.qty()} 個追加しました`);
  }
}

ng new が作る app.html と app.css は使わないので削除して構いません(上のコードは template に直接書いています)。ng serve で起動すれば、ブラウザで同じ画面を操作できます。

動きを確かめるテスト:src/app/app.spec.ts

画面の文字を順に読み取り、「+」を2回押す → 「カートに入れる」を押す → 親側から数量を10にする、という流れを確かめます。

import { TestBed } from '@angular/core/testing';
import { App } from './app';

describe('App', () => {
  it('親子でデータを受け渡す', async () => {
    const fixture = TestBed.createComponent(App);
    await fixture.whenStable();
    const el = fixture.nativeElement as HTMLElement;
    const text = (sel: string) => el.querySelector(sel)!.textContent!.trim();
    console.log('1)', text('h2'), '|', text('.price'), '|', el.querySelector('section')!.className);
    console.log('2)', text('.parent'));
    (el.querySelector('.plus') as HTMLButtonElement).click();
    (el.querySelector('.plus') as HTMLButtonElement).click();
    await fixture.whenStable();
    console.log('3)', text('.price'), '|', text('.parent'));
    (el.querySelector('.add') as HTMLButtonElement).click();
    await fixture.whenStable();
    console.log('4)', text('.log'));
    fixture.componentInstance.qty.set(10);
    await fixture.whenStable();
    console.log('5)', text('.price'));
    expect(text('.parent')).toContain('10');
  });
});

実行コマンドと結果です(console.log の行だけ抜き出しています)。

npx ng test --watch=false --reporters=verbose
1) おすすめ:ノート | 単価 1200 円 × 2 個 = 2400 円 | featured
2) 親が持っている数量: 2
3) 単価 1200 円 × 4 個 = 4800 円 | 親が持っている数量: 4
4) ノート を 4 個追加しました
5) 単価 1200 円 × 10 個 = 12000 円
 ✓ |demo| src/app/app.spec.ts > App > 親子でデータを受け渡す

出力から読み取れることを整理します。

行 何が起きたか 使った機能
1) caption で渡した文字が見出しに、price="1200" が数値になって計算に使われ、featured で class が付いた alias / numberAttribute / booleanAttribute
2) 親の qty(初期値2)が子の quantity に入った model(親 → 子)
3) 子の「+」を2回押すと、親の qty も4になった model(子 → 親)
4) 子の added.emit(name()) が親の onAdded($event) を呼んだ output
5) 親で qty.set(10) すると子の表示と total が変わった model + computed

input():親から子へ値を渡す

基本形と型の決まり方

input(初期値) と書くと、初期値から型が決まります。初期値を省くと undefined を含む型になります。

price = input(0);                 // InputSignal<number>
memo = input<string>();           // InputSignal<string | undefined>
name = input.required<string>();  // InputSignal<string>(必ず渡してもらう)

input() が返すシグナルは読み取り専用です。公式ドキュメントにも、input 関数が作るシグナルは読み取り専用だと明記されています。子の中で値を書き換えたくなったら、後述の model() に切り替えるか、computed() で別の値を作ります。

input.required:必ず渡してもらう

初期値に意味がない入力(名前やIDなど)は input.required<型>() にします。親が渡し忘れるとビルドの時点で知らせてくれるので、画面が空のまま表示されるような見落としに早く気づけます(エラー文は後の章に載せます)。

transform:受け取るときに変換する

HTMLの属性として price="1200" と書いた値は文字列です。transform を付けると、受け取る瞬間に変換できます。Angular には次の2つが用意されています。

関数 親の書き方 子が受け取る値
booleanAttribute featured true
booleanAttribute featured="false" false
booleanAttribute (書かない) 初期値(上の例では false)
numberAttribute price="1200" 1200(数値)
numberAttribute price="abc" NaN

featured="false" が false になる点と、数値にできない文字列が NaN になる点は、今回の環境でも次のように確かめました(親で name="ペン" price="abc" label="表示名" featured="false" と書いた結果)。

I) ペン | 単価 NaN 円 × 1 個 = NaN 円 |class= 

自作の変換も書けます。前後の空白を取り除く例です。

keyword = input('', { transform: (v: string) => v.trim() });

alias:外向きの名前だけ変える

label = input('', { alias: 'caption' }) と書くと、親からは caption、子の中では label という名前になります。既存の親テンプレートを変えずに、子の内部の名前だけ整えたいときに便利です。

ここで1つ気をつけたい点があります。上の実行結果 I) では、親が label="表示名" と内側の名前で書いていますが、見出しは「ペン」のままで、ビルドも通りました。角かっこなしの label="..." は「ただのHTML属性」としても成り立つため、入力として認識されなくてもエラーになりません。別名を付けたら、親は必ず別名(caption)で書く、と覚えておくと迷いません。角かっこ付きの [label]="..." なら NG8002 で知らせてくれます。

入力から別の値を作る:computed と effect

入力はシグナルなので、computed() で「入力から計算した値」を宣言できます。完成コードの total がその例で、price か quantity のどちらかが変わると自動で再計算されます。以前は ngOnChanges に書いていた処理の多くが、この1行に置き換わります。

「入力が変わったらログを出す」「入力が変わったら通信する」のように、値を返さない処理は effect() を使います。

constructor() {
  effect(() => console.log('effect:', this.name()));
}

output():子から親へイベントを伝える

output<型>() で作ったものに対して emit(値) を呼ぶと、親の (added)="..." に書いた式が実行されます。親側では $event で値を受け取ります。

// 子
added = output<string>();
// どこかで
this.added.emit('ノート');
<!-- 親 -->
<app-item-card name="ノート" (added)="onAdded($event)" />

公式ドキュメント(Custom events with outputs)で確認できる要点を表にまとめます。

項目 内容
値なしのイベント closed = output<void>(); と書き、this.closed.emit(); で発火
名前を変える output({ alias: 'itemAdded' })
DOMを伝わるか Angular のカスタムイベントは親要素へ伝播しない(直接の親だけが受け取る)
名前の付け方 camelCase にし、on で始めない。click などの標準イベントと同じ名前は避ける
コードから購読 ref.added.subscribe(...)。コンポーネントの破棄時に Angular が後始末する
デコレータ形式 @Output も引き続き完全にサポートされる

output() は EventEmitter と違い、RxJS の Subject ではありません。emit と subscribe に絞った小さな作りなので、「イベントを知らせる」役割だけに集中できます。

model():親と子で同じ値を双方向に共有する

[(quantity)] の書き方

model() は「書き込みもできる input」です。子で quantity = model(1) と宣言し、親が [(quantity)]="qty" と書くと、親の値が子に入り、子が set / update で書き換えると親の値も変わります。[( )] は形から「バナナ・イン・ア・ボックス」と呼ばれる構文です。

// 子
quantity = model(1);
change(diff: number) {
  this.quantity.update((q) => Math.max(1, q + diff));
}
<!-- 親:qty は signal(2) -->
<app-item-card name="ノート" [(quantity)]="qty" />

親に渡すのはシグナルそのもの(qty)で、qty() と呼ばないのがポイントです。qty() と書いた場合のエラーは次の章に載せます。

親がシグナルでなくても使える

親の値は普通のプロパティでも構いません。また、model() を宣言すると、公式ドキュメントのとおり「名前 + Change」という output が自動で作られるので、(quantityChange) で変更だけを受け取ることもできます。次のホストで確かめました。

@Component({
  imports: [ItemCard],
  template: `<app-item-card name="ペン" [(quantity)]="qty" (quantityChange)="seen = $event" />`,
})
class HostPlain { qty = 3; seen = 0; }

子の「+」を1回押したあとの親の値です。

M) qty = 4  seen = 4

model の使い分け

書き方 向き 向いている場面
[quantity]="qty()" 親 → 子のみ 子の変更を親に反映したくない
[(quantity)]="qty" 親 ⇔ 子 入力部品(数量、チェック、選択中の値など)
(quantityChange)="..." 子 → 親のみ 変更を受けて別の処理をしたい
model.required<number>() 親 ⇔ 子(必須) 初期値を子で決められない

公式ドキュメントには、model 入力は transform に対応していないと書かれています。変換が必要なときは、input() + output() の組にするか、computed() で変換後の値を別に用意します。

よく出るエラーと直し方(実際のエラー文つき)

完成コードを少しずつ変えて ng build したときのメッセージです。どれもビルド時に検出されるので、画面を開く前に気づけます。

一覧表

コード 起きる状況 直し方
NG8008 必須入力を親が渡していない 親に [name]="..." を足す
NG8002 存在しない入力名に [ ] でバインドした 名前のつづり・alias・imports を確認
TS2339 input() に set しようとした model() に変える、または computed() を使う
TS2322 型が合わない/シグナルを呼ばずに渡した [name]="itemName()" のように () を付ける
NG5002 [(x)]="qty()" と呼び出して渡した [(x)]="qty" とシグナルそのものを渡す
TS2345 $event の型と受け取る関数の引数が違う output<型>() と関数の引数をそろえる
NG8118 / NG0950 必須入力をコンストラクタで読んだ ngOnInit・computed・effect の中で読む
NG8109 テンプレートで {{ name }} と () を忘れた(警告) {{ name() }} にする

NG8008:必須入力を渡し忘れた

親テンプレートから [name]="itemName()" の行を消してビルドした結果です。

✘ [ERROR] NG8008: Required input 'name' from component ItemCard must be specified. [plugin angular-compiler]

    src/app/app.ts:9:5:
      9 │     <app-item-card
        ╵      ~~~~~~~~~~~~~

「ItemCard の必須入力 name を指定してください」という意味です。親に [name]="..."(固定の文字なら name="ノート")を足せば解消します。alias を付けた必須入力は、別名のほうで渡す必要があります。

NG8002:入力名のつづり違い

[name] を [nme] と打ち間違えた場合です。

✘ [ERROR] NG8002: Can't bind to 'nme' since it isn't a known property of 'app-item-card'.
1. If 'app-item-card' is an Angular component and it has 'nme' input, then verify that it is included in the '@Component.imports' of this component.
2. If 'app-item-card' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@Component.schemas' of this component to suppress this message.
3. To allow any property add 'NO_ERRORS_SCHEMA' to the '@Component.schemas' of this component. Find more at https://next.angular.dev/errors/NG8002 [plugin angular-compiler]

    src/app/app.ts:10:6:
      10 │       [nme]="itemName()"
         ╵       ~~~~~~~~~~~~~~~~~~

このとき name が渡っていないので、NG8008 も同時に表示されました。NG8002 は、子コンポーネントを親の imports に入れ忘れたときにも出ます。つづり → alias → imports の順に確認すると早く見つかります。

TS2339:input に set しようとした

子の中に this.name.set("x") を書いた場合です。

✘ [ERROR] TS2339: Property 'set' does not exist on type 'InputSignal<string>'. [plugin angular-compiler]

    src/app/item-card.ts:32:14:
      32 │     this.name.set("x");
         ╵               ~~~

InputSignal には set がありません。直し方は目的で変わります。

目的 直し方
子で変えた値を親にも返したい name = model.required<string>() にして、親は [(name)] で渡す
子の中だけで加工した値を使いたい shown = computed(() => this.name().trim())
子の中だけで一時的に上書きしたい 別に signal() を持ち、表示はそちらを優先する

TS2322 と NG5002:かっこの有無を取り違えた

片方向の [name] にシグナルを呼ばずに渡した場合は TS2322 です。

✘ [ERROR] TS2322: Type 'WritableSignal<string>' is not assignable to type 'string'. [plugin angular-compiler]

    src/app/app.ts:10:6:
      10 │       [name]="itemName"
         ╵       ~~~~~~~~~~~~~~~~~

逆に、双方向の [( )] にシグナルを呼んで渡した場合は NG5002 です。

✘ [ERROR] NG5002: Unsupported expression in a two-way binding [plugin angular-compiler]

    src/app/app.ts:14:6:
      14 │       [(quantity)]="qty()"
         ╵       ~~~~~~~~~~~~~~~~~~~~

覚え方は次の表のとおりです。

バインド 渡すもの 書き方
[name](片方向) 中身の値 [name]="itemName()"
[(quantity)](双方向) 書き込み先そのもの [(quantity)]="qty"

なお、数値の model に文字列のシグナルを渡すと、TS2322: Type 'string' is not assignable to type 'number'. が出ました。型の取り違えもビルドで分かります。

TS2345:$event の型が合わない

added = output<string>() に対して、親の関数を onAdded(name: number) にした場合です。

✘ [ERROR] TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. [plugin angular-compiler]

    src/app/app.ts:15:23:
      15 │       (added)="onAdded($event)"
         ╵                        ~~~~~~

output<型>() の型がそのまま $event の型になります。受け取る関数の引数を合わせましょう。

NG8118/NG0950:必須入力を読むタイミングが早い

必須入力は、コンポーネントが作られた直後(コンストラクタの時点)にはまだ値が入っていません。コンストラクタに console.log(this.name()) と直接書くと、ビルドで次のように知らせてくれます。

✘ [ERROR] NG8118: `name` is a required `input` and does not have a value in this context. [plugin angular-compiler]

関数を1つ挟むなどしてビルドを通過した場合は、実行時に次のエラーになります。

D) NG0950: Input "name" is required but no value is available yet. Find more at https://v22.angular.dev/errors/NG0950

読む場所を ngOnInit か effect()(または computed())に移すと動きます。次のコンポーネントで、親が値を「ノート」→「ペン」と変えたときの出力を確かめました。

@Component({ selector: 'app-late', template: `{{ name() }}` })
class Late {
  name = input.required<string>();
  constructor() {
    effect(() => console.log('effect:', this.name()));
  }
  ngOnInit() {
    console.log('ngOnInit:', this.name());
  }
}
ngOnInit: ノート
effect: ノート
effect: ペン

ngOnInit は最初の1回だけ、effect は値が変わるたびに動いていることが分かります。

NG8109:テンプレートで () を付け忘れた

{{ name }} と書いた場合はエラーではなく警告になり、画面にはシグナルの説明文がそのまま表示されました。

▲ [WARNING] NG8109: NG8109: name is a function and should be invoked: name()}. Find more at https://v22.angular.dev/extended-diagnostics/NG8109 [plugin angular-compiler]
E) [Input Signal: ノート]

画面に [Input Signal: ...] と出ていたら、() の付け忘れを探してください。

既存の @Input/@Output を自動で書き換える(移行コマンド)

Angular には、デコレータ形式を関数形式へ書き換える移行コマンド(schematic)が用意されています。完成コードと同じ内容をデコレータ形式で書いた item-card-legacy.ts を用意し、実際に実行しました。

移行前のファイル

import { Component, EventEmitter, Input, Output, booleanAttribute, numberAttribute } from '@angular/core';

@Component({
  selector: 'app-item-card-legacy',
  template: `
    <section [class.featured]="featured">
      <h2>{{ label || name }}</h2>
      <p class="price">単価 {{ price }} 円 × {{ quantity }} 個 = {{ price * quantity }} 円</p>
      <button type="button" class="plus" (click)="change(1)">+</button>
      <button type="button" class="add" (click)="added.emit(name)">カートに入れる</button>
    </section>
  `,
})
export class ItemCardLegacy {
  @Input({ required: true }) name!: string;
  @Input({ transform: numberAttribute }) price = 0;
  @Input({ transform: booleanAttribute }) featured = false;
  @Input('caption') label = '';

  // 双方向は「quantity」と「quantityChange」の組で作る
  @Input() quantity = 1;
  @Output() quantityChange = new EventEmitter<number>();

  @Output() added = new EventEmitter<string>();

  change(diff: number) {
    this.quantity = Math.max(1, this.quantity + diff);
    this.quantityChange.emit(this.quantity);
  }
}

signal-input-migration の実行結果

npx ng generate @angular/core:signal-input-migration --defaults --insert-todos
    Preparing analysis for: tsconfig.app.json...
    Scanning for inputs: tsconfig.app.json...

    Processing analysis data between targets...

    Running migration for: tsconfig.app.json...

    Successfully migrated to signal inputs 🎉
      -> Migrated 4/5 inputs.
UPDATE src/app/legacy/item-card-legacy.ts (1269 bytes)

5つの入力のうち4つが変換されました。変換差分(diff -u、import 部分は省略)は次のとおりです。

-    <section [class.featured]="featured">
-      <h2>{{ label || name }}</h2>
-      <p class="price">単価 {{ price }} 円 × {{ quantity }} 個 = {{ price * quantity }} 円</p>
+    <section [class.featured]="featured()">
+      <h2>{{ label() || name() }}</h2>
+      <p class="price">単価 {{ price() }} 円 × {{ quantity }} 個 = {{ price() * quantity }} 円</p>
       <button type="button" class="plus" (click)="change(1)">+</button>
-      <button type="button" class="add" (click)="added.emit(name)">カートに入れる</button>
+      <button type="button" class="add" (click)="added.emit(name())">カートに入れる</button>
     </section>
   `,
 })
 export class ItemCardLegacy {
-  @Input({ required: true }) name!: string;
-  @Input({ transform: numberAttribute }) price = 0;
-  @Input({ transform: booleanAttribute }) featured = false;
-  @Input('caption') label = '';
+  readonly name = input.required<string>();
+  readonly price = input(0, { transform: numberAttribute });
+  readonly featured = input(false, { transform: booleanAttribute });
+  readonly label = input('', { alias: 'caption' });

   // 双方向は「quantity」と「quantityChange」の組で作る
+  // TODO: Skipped for migration because:
+  //  Your application code writes to the input. This prevents migration.
   @Input() quantity = 1;
   @Output() quantityChange = new EventEmitter<number>();

読み取れるポイントは3つです。

  • 宣言だけでなく、テンプレート内の参照(name → name())も一緒に書き換わる
  • required・transform・別名は、それぞれ input.required・transform・alias に正しく移される
  • クラスの中で書き込んでいる入力(this.quantity = ...)は変換されず、--insert-todos を付けると理由がコメントで残る

output-migration の実行結果

続けて output も移行します。

npx ng generate @angular/core:output-migration --defaults
    Successfully migrated to outputs as functions 🎉
      -> Migrated 2 out of 2 detected outputs (100.00 %).
UPDATE src/app/legacy/item-card-legacy.ts (1245 bytes)
-  @Output() quantityChange = new EventEmitter<number>();
+  readonly quantityChange = output<number>();

-  @Output() added = new EventEmitter<string>();
+  readonly added = output<string>();

2回の移行のあとも、このコンポーネントを使うテスト([(quantity)] と (added) を使う親)はそのまま通りました。

legacy) おすすめ:ノート | 単価 1200 円 × 3 個 = 3600 円 | qty = 3 | log = ノート を追加

残った quantity を model() にまとめる

変換されずに残った @Input() quantity と quantityChange の組は、手で model() に置き換えられます。

// 置き換え前(2つの宣言 + emit)
@Input() quantity = 1;
readonly quantityChange = output<number>();
change(diff: number) {
  this.quantity = Math.max(1, this.quantity + diff);
  this.quantityChange.emit(this.quantity);
}

// 置き換え後(1つの宣言)
quantity = model(1);
change(diff: number) {
  this.quantity.update((q) => Math.max(1, q + diff));
}

テンプレート内の quantity を quantity() に直せば、最初に示した完成コードと同じ形になります。

移行コマンドのオプション

公式ドキュメント(Migration to signal inputs)に載っているオプションです。

オプション 役割
--path 移行する範囲を特定のフォルダに絞る
--analysis-dir 参照を調べる範囲を絞る(大きなプロジェクトで時間を短くする)
--insert-todos 変換を見送った入力に、理由を書いた TODO コメントを入れる
--best-effort-mode 見送られるパターンも含めてできるだけ変換する(既定は無効。ビルドが通らなくなる場合があるので、変換後に必ずビルドする)

先に --dry-run を付けて実行すると、ファイルを書き換えずに対象だけ確認できます。まず --path で1フォルダだけ移行 → ビルドとテスト → 範囲を広げる、という順に進めると安心です。

どれを使うかの判断表

状況 選ぶもの 理由
子は表示するだけ input() 読み取り専用なので、値の流れが親 → 子の一方向に保たれる
無いと表示できない値 input.required() 渡し忘れをビルドで検出できる
ボタンが押された・保存したなど「出来事」を伝える output() 値ではなくタイミングを伝える
入力欄・数量・開閉状態など「値」を共有する model() 宣言が1行で、親は [( )] だけで済む
親子でなく、離れたコンポーネント同士で共有する サービスにシグナルを持たせる 入力のバケツリレーを避けられる
既存のデコレータ形式が大量にある そのまま使いつつ、移行コマンドで少しずつ どちらも同じテンプレート構文で共存できる

チェックリスト

  • [ ] 子の入力は input()、必須なら input.required<型>() にした
  • [ ] テンプレートとクラスの中で name() とかっこを付けて読んでいる
  • [ ] 片方向は [name]="x()"、双方向は [(name)]="x" と書き分けている
  • [ ] alias を付けた入力は、親が別名で書いている
  • [ ] 文字列で渡す数値・真偽値には numberAttribute / booleanAttribute を付けた
  • [ ] 必須入力をコンストラクタで読んでいない(ngOnInit・computed・effect で読む)
  • [ ] output<型>() の型と、親の受け取り関数の引数がそろっている
  • [ ] 子コンポーネントを親の imports に入れた

よくある質問

Q. @Input と @Output はもう使えないのですか? A. 使えます。公式ドキュメントでは、新しいコードには関数形式の input / output を推奨しつつ、デコレータ形式も引き続き完全にサポートすると説明されています。同じプロジェクトの中で両方が共存でき、親テンプレートの書き方も共通です。

Q. input() と model() はどう使い分けますか? A. 子が値を「読むだけ」なら input()、子が値を「書き換えて親にも反映したい」なら model() です。input() は set を持たないため、書き込もうとすると TS2339 になります。迷ったら input() で始め、書き込みが必要になった時点で model() に変えるのが分かりやすい進め方です。

Q. ngOnChanges は書かなくてよいのですか? A. 関数形式の入力では、computed()(入力から値を作る)と effect()(入力が変わったら処理する)で同じ目的を果たせます。この記事の total のように、計算で求まる値は computed にしておくと、どの入力が変わっても自動で最新になります。

Q. 孫コンポーネントまで値を渡すにはどうしますか? A. 子が受け取った値を、さらに孫へ [name]="name()" と渡します。Angular のカスタムイベントはDOMを伝播しないので、孫の output も子が受けて、子の output として出し直します。階層が深くなってきたら、シグナルを持つサービスを注入して共有する形が向いています。

まとめ

  • 親 → 子は input()、子 → 親は output()、親 ⇔ 子は model() の3つを覚えれば、親子の受け渡しはひととおり書ける
  • input() は読み取り専用のシグナル。name() とかっこを付けて読み、加工は computed()、反応は effect() で書く
  • input.required を付けると、渡し忘れが NG8008 としてビルド時に分かる
  • transform(numberAttribute / booleanAttribute)と alias で、親からの書きやすさを整えられる。別名を付けたら親は別名で書く
  • 双方向は [(quantity)]="qty" とシグナルそのものを渡す。qty() と呼ぶと NG5002 になる
  • デコレータ形式は ng generate @angular/core:signal-input-migration と output-migration で書き換えられ、クラス内で書き込んでいる入力だけが残るので、そこを model() にまとめる

参考・出典

  • Angular 公式ドキュメント「Accepting data with input properties」 https://angular.dev/guide/components/inputs
  • Angular 公式ドキュメント「Custom events with outputs」 https://angular.dev/guide/components/outputs
  • Angular 公式ドキュメント「Migration to signal inputs」 https://angular.dev/reference/migrations/signal-inputs

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular input・output・modelの使い方|親子の受け渡し」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?