テックブログ

Angular実践:Standaloneで最小アプリ(Input/Output+Service分離)

Angular実践:Standaloneで最小アプリ(Input/Output+Service分離)

AngularのStandalone構成では、画面はコンポーネントを組み合わせて作り、親子間のデータ受け渡しは@Input/@Outputで行います。状態や処理をコンポーネント内に抱えすぎると見通しが悪くなるため、取得・更新などのロジックはServiceに切り出し、DIで注入して利用するのが基本になります。これにより、UIは表示とイベントに集中でき、差し替えやテストもしやすくなります。本記事では、この設計の型を「一覧→詳細→追加の入口」を持つ最小アプリで確認します。

関連記事:現代Angularの全体像:StandaloneとDIからRouterまで

1. 作るもの(完成イメージ)

1-1. 一覧→詳細の最小UIとデータ構造

作るのは、商品一覧を表示して、選んだ1件の詳細を右側または下側に出す最小アプリです。Angularの実務でまず必要になる「一覧コンポーネント」「詳細コンポーネント」「親画面で状態を持つ」という流れを、小さく確認するのが目的です。

一覧→詳細は、Angularの基本要素を一通り試しやすい題材です。親コンポーネントが「どの商品を選んだか」という状態を持ち、一覧子コンポーネントには配列を渡し、詳細子コンポーネントには選択中の商品を渡します。こうすると、コンポーネントの責務、@Input の使い方、イベントでの状態更新が自然に見えてきます。

データ構造は、まずは次のようなシンプルな Item 型で十分です。実務ではここに在庫数やカテゴリなどが増えていきますが、最初は「id / name / description」くらいの最小構成のほうが、画面構造とデータ構造の関係をつかみやすいです。

export interface Item {
  id: number;
  name: string;
  description: string;
}

この型では、一覧表示に必要な最小情報と、詳細表示で見せたい説明文だけを持たせています。最初から項目を増やしすぎるとUI調整に気を取られやすいので、「コンポーネント連携を学ぶ」という目的に合わせて型も小さく保つのがコツです。

1-2. 追加/更新の入口(フォーム or 簡易入力)

一覧と詳細だけだと「表示するだけ」で終わってしまうので、今回は追加の入口も作ります。完全なフォーム画面まで広げず、親コンポーネントに簡易入力欄を置いて、商品を1件追加できる形にするのがちょうどよいです。

追加の入口を入れる理由は、親子コンポーネントの通信だけでなく、「画面イベントからServiceを呼んで状態を更新する」流れを確認するためです。さらに、追加後に一覧が更新され、必要なら選択中の商品も差し替わる、という一連の動きを見ることで、Angularアプリの最小のデータフローが見えてきます。

この段階では、厳密なバリデーションやReactive Formsまでは広げなくて大丈夫です。まずはテキストボックスとボタンで追加できれば十分で、「UI入力 → 親コンポーネント → Service → 一覧更新」という流れを作ることを優先すると、次にフォームやテストへ広げやすくなります。

2. プロジェクト作成とStandalone構成

2-1. ng new と最低限の構成理解

Angular Standaloneの最小アプリを始めるなら、まずはCLIで素直に新規作成するのが近道です。設定を手で組むこともできますが、1〜2年目のうちはCLIが作る標準構成をベースに理解したほうが、後で公式ドキュメントとも対応づけやすくなります。

今のAngularでは、Standalone構成を前提にしたプロジェクト作成がしやすくなっています。CLIが用意するファイルの中でも、特に見るべきなのは main.ts、ルートコンポーネント、必要なら app.config.ts のようなアプリ全体設定です。NgModule中心でなく、bootstrapApplication() から始まる構造を先に読むと、「今のAngularはこう組むのか」がつかみやすくなります。

ng new standalone-demo
cd standalone-demo
ng serve

この手順で作成した直後は、まずブラウザで起動確認をしてから、src/app の中身を見てください。最初に全部を理解しようとせず、「起動入口はどこか」「最初のコンポーネントはどれか」を押さえるだけでも十分です。

2-2. Standaloneコンポーネントの作り方と配置

Standaloneコンポーネントは、自分で使う依存を自分で宣言するコンポーネントだと考えると分かりやすいです。NgModuleにまとめて宣言するのではなく、各コンポーネントが standalone: trueimports を持つことで、必要なものを明示できます。

この構成の利点は、「どのコンポーネントが何に依存しているか」をその場で読めることです。特に小〜中規模の画面単位では、NgModuleに分散して定義を追うよりも、コンポーネントファイルを開くだけで使っているものが分かるほうが理解しやすいです。新規機能を追加するときも、必要な依存をそのコンポーネントに閉じ込めやすくなります。

ng generate component components/item-list --standalone
ng generate component components/item-detail --standalone

配置は、最初は components/ にUI部品を寄せ、Serviceは services/ に置くくらいで十分です。画面数が増えてきたら feature 単位のフォルダ分割へ進めばよいので、最初から重い構成にしすぎないほうが学習しやすいです。

3. 親子コンポーネントで画面を組む

3-1. @Input で親→子に渡す

@Input は、親コンポーネントが持つ値を子コンポーネントへ渡す仕組みです。Angularの画面分割ではまずここが基本で、一覧データや選択中データを子へ渡すときに使います。

考え方としては、親が状態の持ち主で、子はそれを受け取って表示する側です。この分担にすると、どこで状態が変わるかが分かりやすくなります。特に一覧・詳細のような構成では、「一覧配列は親が持ち、一覧子に渡す」「選択中の1件も親が持ち、詳細子に渡す」と整理すると読みやすいです。

import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Item } from '../models/item';

@Component({
  selector: 'app-item-detail',
  standalone: true,
  imports: [CommonModule],
  template: `
    <section *ngIf="item; else empty">
      <h3>{{ item.name }}</h3>
      <p>{{ item.description }}</p>
    </section>

    <ng-template #empty>
      <p>商品を選択してください。</p>
    </ng-template>
  `
})
export class ItemDetailComponent {
  @Input() item: Item | null = null;
}

このコードでは、親から受け取った item を詳細表示に使っています。子コンポーネント側で勝手にデータ取得まで始めるより、まずは「表示専用」として責務を小さく保つほうが、あとでテストもしやすくなります。

3-2. @Output で子→親に通知する(イベント設計)

@Output は、子コンポーネントで起きたイベントを親へ通知する仕組みです。親が状態を持ち、子は「選ばれた」「削除ボタンが押された」といった事実だけを伝える、という役割分担にするときれいに使えます。

重要なのは、子が親の状態を直接書き換えないことです。子はEventEmitterでイベントを発火し、親がそのイベントを受けて状態更新やService呼び出しを行います。これにより、子は「どう更新されるか」を知らずに済み、再利用しやすくなります。

import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Item } from '../models/item';

@Component({
  selector: 'app-item-list',
  standalone: true,
  imports: [CommonModule],
  template: `
    <ul>
      <li *ngFor="let item of items">
        <button type="button" (click)="selectItem(item)">
          {{ item.name }}
        </button>
      </li>
    </ul>
  `
})
export class ItemListComponent {
  @Input() items: Item[] = [];
  @Output() selected = new EventEmitter<Item>();

  selectItem(item: Item): void {
    this.selected.emit(item);
  }
}

このコードでは、子は「どの商品がクリックされたか」だけを親へ通知しています。落とし穴は、EventEmitterを“何でも返す手段”として使いすぎることです。イベント名は「何が起きたか」が分かる名前にして、責務をあいまいにしないほうが実務では読みやすくなります。

4. Service分離(DIで注入して使う)

4-1. UIロジックとビジネスロジックを分ける基準

AngularでService分離を考えるときは、画面に閉じた処理か、再利用・共有したい処理かを基準にすると分けやすいです。UI表示やクリック時の一時的な状態はコンポーネントに置き、データ取得や一覧更新のルールはServiceへ寄せるのが基本です。

こうしておくと、コンポーネントは「画面をどう見せるか」に集中できます。一方、Serviceは「データをどう扱うか」を担当するので、同じロジックを別画面でも使いやすくなります。さらに、後でテストを書くときも、Service単体で振る舞いを確認しやすくなります。

import { Injectable } from '@angular/core';
import { Item } from '../models/item';

@Injectable({ providedIn: 'root' })
export class ItemService {
  private items: Item[] = [
    { id: 1, name: 'Angular Book', description: 'Angularの入門書です。' },
    { id: 2, name: 'TypeScript Guide', description: '型安全に強くなるための本です。' }
  ];

  getItems(): Item[] {
    return this.items;
  }

  addItem(name: string): void {
    const nextId = this.items.length + 1;
    this.items = [
      ...this.items,
      { id: nextId, name, description: `${name} の説明です。` }
    ];
  }
}

このServiceでは、一覧取得と追加のロジックだけを持たせています。まだ小さい例ですが、「データ操作をコンポーネントから外に出す」だけで責務がかなり整理されます。後でHTTPに置き換えるときも、この境界があると修正範囲を小さくできます。

4-2. Serviceを差し替えられる形にする(テストの準備)

次の記事でTestBedにつなげるなら、Serviceを差し替えられる前提でコンポーネントを書くことが大事です。つまり、コンポーネントはServiceの具体実装にべったり依存せず、「DIで渡される依存」として扱う形にしておきます。

Angularでは、コンストラクタ注入でも inject() でもDIできますが、重要なのは“自分でnewしない”ことです。DIコンテナに解決を任せておけば、実行時は本物のService、テスト時はモックやスタブに差し替えられます。これがAngularでテストしやすい構造を作る基本です。

import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Item } from './models/item';
import { ItemService } from './services/item.service';
import { ItemListComponent } from './components/item-list.component';
import { ItemDetailComponent } from './components/item-detail.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ItemListComponent, ItemDetailComponent],
  template: `
    <h1>商品一覧</h1>

    <div>
      <input [(ngModel)]="newItemName" placeholder="新しい商品名" />
      <button type="button" (click)="addItem()">追加</button>
    </div>

    <app-item-list
      [items]="items"
      (selected)="onSelected($event)">
    </app-item-list>

    <app-item-detail [item]="selectedItem"></app-item-detail>
  `
})
export class AppComponent {
  private itemService = inject(ItemService);

  items = this.itemService.getItems();
  selectedItem: Item | null = null;
  newItemName = '';

  onSelected(item: Item): void {
    this.selectedItem = item;
  }

  addItem(): void {
    if (!this.newItemName.trim()) return;
    this.itemService.addItem(this.newItemName.trim());
    this.items = this.itemService.getItems();
    this.newItemName = '';
  }
}

このコードでは、コンポーネントが inject(ItemService) でServiceを受け取っています。次にTestBedを書くときは、この依存をテスト用のServiceへ差し替えられます。将来のテストを楽にするためにも、「依存はDIから受け取る」を徹底しておくのが重要です。

5. データ取得の形

5-1. HttpClientの使い方(GETだけでOK)

HTTPは今回の主役ではありませんが、Angular実務では避けて通れないので、GETだけ最小で触れておくのがよいです。最初からPOSTやInterceptorまで広げず、「ServiceでHttpClientを使って一覧を取る」形だけ確認すれば十分です。

AngularのHTTP通信は HttpClient を使い、Observableで結果を受け取ります。これにより、非同期処理をコンポーネントの外に置きつつ、購読タイミングやエラー処理を整理できます。まずは「コンポーネントはServiceを呼ぶ」「ServiceがHttpClientを使う」という役割分担に慣れることが大切です。

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Item } from '../models/item';

@Injectable({ providedIn: 'root' })
export class ItemApiService {
  private http = inject(HttpClient);

  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('/api/items');
  }
}

このコードでは、ServiceがHTTP通信の責務を持っています。重要なのは、コンポーネントが直接URLやHTTP詳細を知らなくてよいことです。もし将来API仕様が変わっても、修正はServiceに閉じ込めやすくなります。

5-2. ローディング/エラー表示の最低限

HTTP通信を入れるなら、ローディングとエラー表示の最低限もセットで考えるべきです。成功時だけを前提にすると、実務ではすぐに破綻します。

最初は大げさな状態管理をしなくても、loadingerrorMessage の2つを持つだけで十分です。読み込み開始でloadingをtrueにし、成功・失敗のどちらでも最後にfalseへ戻す。失敗時は短いメッセージを出す。このくらいの基本形を持っておくと、後で共通化もしやすくなります。

loading = false;
errorMessage = '';

loadItems(): void {
  this.loading = true;
  this.errorMessage = '';

  this.itemApiService.getItems().subscribe({
    next: (items) => {
      this.items = items;
      this.loading = false;
    },
    error: () => {
      this.errorMessage = '商品の取得に失敗しました。';
      this.loading = false;
    }
  });
}

このコードでは、成功と失敗の両方で loading を戻しています。落とし穴は、「成功時しかloadingを戻していない」「エラー内容をUIに出す場所がない」ことです。最初から最低限の状態を持っておくと、ユーザー体験もコードの読みやすさも改善しやすくなります。

6. まとめ:テストしやすい境界

今回の構成で一番大事なのは、どこがテストしやすい境界かを意識して分けたことです。UI表示はコンポーネント、データ操作や取得はServiceという形にしておくと、テスト対象がかなり明確になります。

たとえば、一覧子コンポーネントは「Inputで受けた配列を描画し、クリック時にOutputを出す」ことを見ればよく、詳細子コンポーネントは「Inputの中身を表示できるか」を確認すれば十分です。一方、Serviceは「追加したら配列が増える」「HTTPレスポンスを期待型で返す」といったロジック単位で見られます。こうして責務を分けると、テストの粒度も自然に決めやすくなります。

逆に、コンポーネントの中にHTTP通信、配列更新、複雑な条件分岐が全部入っていると、どこをどう検証すべきかがあいまいになります。今回のように最初からService分離しておくと、後でTestBedやモックを使う練習にもつながりやすいです。

7. 参考リンク