- Angular
Angular input・output・modelの使い方|親子の受け渡し
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期連続の黒字決算。