← 解説「賢い親と表示に徹する子 — 役割で層を分ける」に戻る
サンプルコードで身につける: 賢い親と表示に徹する子 — 役割で層を分ける
解説で学んだ概念を、5つの具体例で確認します。素朴な例から実務寄りの例へと進みます。
1ページコンポーネント — データ取得と状態を担当する親
親はサービスからデータを取得し、状態を signal で持ち、子の Output を受けて処理を決めます。通信や状態管理といった「賢さ」をページ側に集めるのがこの設計の要です。
import { Component, inject, signal } from '@angular/core';
import { ProductCard } from './product-card';
import { ProductService } from './product.service';
import { Product } from './product.model';
// ページコンポーネント = 賢い親。取得・状態・判断を担当する
@Component({
selector: 'app-product-list-page',
imports: [ProductCard],
template:
'@for (p of products(); track p.id) {' +
' <app-product-card [product]="p" (addToCart)="onAdd($event)" />' +
'}',
})
export class ProductListPage {
private service = inject(ProductService);
products = signal<Product[]>([]);
constructor() {
this.service.fetchAll().subscribe((list) => this.products.set(list));
}
onAdd(product: Product) {
this.service.addToCart(product); // 子から届いた通知を受けて判断する
}
}
2表示コンポーネント — 受け取って表示し、通知するだけの子
子は input() で受け取った値を表示し、操作を output() で親へ通知するだけです。サービスも通信も知らないので、商品一覧でもおすすめ欄でも検索結果でも同じように使えます。
import { Component, input, output } from '@angular/core';
import { Product } from './product.model';
// 表示コンポーネント = 表示に徹する子
@Component({
selector: 'app-product-card',
template:
'<div class="card">' +
' <h3>{{ product().name }}</h3>' +
' <p>{{ product().price }}円</p>' +
' <button (click)="addToCart.emit(product())">カートへ</button>' +
'</div>',
})
export class ProductCard {
// 受け取る(Input)と知らせる(Output)だけ。それ以外を持たない
product = input.required<Product>();
addToCart = output<Product>();
}
// カートに入れた後どうなるかは親の仕事。この部品は関知しない
3Before/After — 子がサービスを触ると何が困るか
Beforeでは子が CartService に直接依存しているため、カートと無関係な画面では使えず、テストにもサービスの準備が要ります。Afterは通知するだけなので、その縛りが消えます。
import { Component, inject, input, output } from '@angular/core';
import { CartService } from './cart.service';
import { Product } from './product.model';
// ── Before: 子が inject() でサービスを直接触っている ──
@Component({ selector: 'app-card-bad', template: '...' })
export class CardBad {
private cart = inject(CartService); // この部品はカート専用になる
product = input.required<Product>();
onClick() {
this.cart.add(this.product()); // 「お気に入りに追加」画面では使えない
}
}
// ── After: 子は通知だけ。何をするかは親が決める ──
@Component({ selector: 'app-card', template: '...' })
export class Card {
product = input.required<Product>();
selected = output<Product>();
onClick() {
this.selected.emit(this.product());
}
}
// カートに入れる画面でも、お気に入り画面でも、同じ Card が使える
4表示部品はテストが単純になる
サービスに依存しない表示部品のテストは「値を渡して表示を確かめる」だけで完結します。モックの準備が要らないため、テストの数を増やしても保守の負担が軽く済みます。
// 表示コンポーネントのテストは「渡して・確かめる」だけ
//
// it('商品名と価格が表示される', () => {
// const fixture = TestBed.createComponent(ProductCard);
// // input() に値を渡す
// fixture.componentRef.setInput('product', {
// id: 1, name: 'りんご', price: 120, stock: 3,
// });
// fixture.detectChanges();
//
// const text = fixture.nativeElement.textContent;
// expect(text).toContain('りんご');
// expect(text).toContain('120円');
// });
//
// サービスのモックも HttpClient の準備も不要。
// 子がサービスに依存していたら、この単純さは手に入らない
5どこまで分担を徹底するか — 現実的なさじ加減
小さなアプリで全コンポーネントを厳密に2層へ分ける必要はありません。まず「画面の入口になるコンポーネントを賢い親とみなす」だけでも、通信コードの散らばりを防ぐ効果があります。
// ── 現実的な適用のしかた ──
//
// 最低限守ると効果が大きいルール:
// ・サービスの inject() と HTTP 通信は「ページ(ルートに紐づく
// コンポーネント)」に寄せる
// ・繰り返し表示される部品(カード、行、バッジ)は
// Input/Output だけの表示部品にする
//
// 神経質にならなくてよいところ:
// ・1画面にしか出ない小さな塊まで無理に子へ分けない
// ・フォーム画面など、状態と表示が密なものはページに残してよい
//
// 「通信と状態は上へ、表示は下へ」の方向感さえ守れば、
// 完全な2層分けにこだわらなくても設計は崩れにくい