Angular実践 #3 フォーム + API で Product CRUD

読了 10分

#2 ではログイン・ログアウトのフローと認証ガードまで仕上げました。トークンを Interceptor に乗せてサーバーに送るところまで敷いてあるので、いよいよ本物のデータを扱う時間です。今回は本アプリの中心ドメインである Product を CRUD — 作成・取得・更新・削除 — できる画面を作っていきます。

この記事で回収する道具がかなり多いです。中級 #1 Reactive Forms のフォームモデル、中級 #6 Resolver のデータ事前ロード、そして基礎で扱った HttpClient と Signals まで。覚えてきた道具を 1 つの画面に組み合わせる練習だと考えてください。

バックエンド API の取り決め #

コードを書く前に、サーバーとどんな形でやり取りするかの合意を先に取ります。実践講座ではバックエンドは自分で作らないので、#1 で立ち上げた mock サーバーが次の 5 つのエンドポイントを提供すると仮定します。

メソッドパス説明
GET/api/products一覧
POST/api/products作成
GET/api/products/:id1 件取得
PATCH/api/products/:id部分更新
DELETE/api/products/:id削除

レスポンスの形はシンプルです。

src/app/products/product.model.ts
export interface Product {
  id: number;
  name: string;
  price: number;
  category: 'food' | 'drink' | 'goods';
  description: string;
  createdAt: string;
}

export type NewProduct = Omit<Product, 'id' | 'createdAt'>;
export type ProductPatch = Partial<NewProduct>;

Product はサーバーが返す完成形、NewProduct は作成時に送る形 (サーバーが idcreatedAt を付ける)、ProductPatch は更新時に送る部分オブジェクトです。型をこのように 3 つに分けておくと、画面ごとに何を扱っているかで迷うことが減ります。

ProductService — 4 つのメソッド #

サービスクラスから作ります。すべてのメソッドは Observable を返し、呼び出し側が必要に応じて subscribe するか toSignal で変換して使います。

src/app/products/product.service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);
  private base = '/api/products';

  list() { return this.http.get<Product[]>(this.base); }
  get(id: number) { return this.http.get<Product>(`${this.base}/${id}`); }
  create(payload: NewProduct) { return this.http.post<Product>(this.base, payload); }
  update(id: number, patch: ProductPatch) {
    return this.http.patch<Product>(`${this.base}/${id}`, patch);
  }
  remove(id: number) { return this.http.delete<void>(`${this.base}/${id}`); }
}

@Injectable({ providedIn: 'root' }) でアプリ全体を通じて単一インスタンスとして動きます。ネットワーク呼び出し以外の加工ロジックは意図的に置いていません — データ変形は呼び出し側のコンポーネントが自分の事情に合わせて処理するほうが、再利用性が良くなります。

Product 一覧画面 #

一覧は最もシンプルなパターンです。コンポーネントが立ち上がるとすぐに list() を呼び、その結果を toSignal で受け、@for で描きます。

src/app/pages/product-list.component.ts
@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [RouterLink],
  templateUrl: './product-list.component.html',
})
export class ProductListComponent {
  private products = inject(ProductService);

  protected readonly state = toSignal(
    this.products.list().pipe(catchError(() => of(null))),
    { initialValue: 'loading' as const },
  );
}

toSignal は Observable を Signal に変える橋渡しです。初期値を 'loading' 文字列にしておき、正常レスポンスが来たら配列に、エラーなら null に差し替えます。結果として state() は 3 つの値のいずれかになります — 'loading' / Product[] / null。この 3 つをテンプレートで分岐すれば OK です。

src/app/pages/product-list.component.html
<header>
  <h1>商品一覧</h1>
  <a routerLink="/products/new">+ 商品を追加</a>
</header>

@if (state() === 'loading') {
  <p>読み込み中...</p>
} @else if (state() === null) {
  <p class="error">商品を読み込めませんでした。しばらくしてからもう一度お試しください。</p>
} @else if (state()!.length === 0) {
  <p class="empty">まだ登録された商品がありません。</p>
} @else {
  <ul class="grid">
    @for (p of state(); track p.id) {
      <li class="card">
        <a [routerLink]="['/products', p.id]">
          <h3>{{ p.name }}</h3>
          <p>{{ p.price | number }}円</p>
        </a>
      </li>
    }
  </ul>
}

ローディング・エラー・空状態・正常 — 4 つの分岐を @if / @else if / @else のひと固まりに収めて表現しました。各分岐がどの状態を指しているのかが一目で分かります。空状態 (empty state) を別途用意するのは些細に見えても、ユーザビリティに大きく影響します — 「初めて入ったのに画面が空っぽ」という印象をユーザーに与えないでください。

ヒント
初期値を 'loading' にした理由は、初回レンダリング時点で Signal が空にならないようにするため です。toSignal はデフォルトで Observable が最初の値を出すまで undefined を返すので、テンプレートで undefined も分岐に含める必要が出てきます。明示的な sentinel 値 (ここでは文字列 'loading') を置くと、分岐がきれいになります。

Product 詳細画面 — Resolver で先に受け取る #

詳細画面は Resolver パターンがよく合います。画面が描かれた瞬間からデータが手元にあれば、「データなし」分岐を用意する必要がなくなるからです。

src/app/products/product.resolver.ts
export const productResolver: ResolveFn<Product | null> = (route) => {
  const id = Number(route.paramMap.get('id'));
  const router = inject(Router);

  return inject(ProductService).get(id).pipe(
    catchError(() => {
      router.navigate(['/products']);
      return of(null);
    }),
  );
};

存在しない id でアクセスしてきた場合は一覧に戻し、null を流します。ルートには resolve: { product: productResolver } をキーと値で登録し、詳細コンポーネントは lazy load で繋ぎます。

コンポーネントは ActivatedRoutedata から結果を取り出します。

src/app/pages/product-detail.component.ts
@Component({
  selector: 'app-product-detail',
  standalone: true,
  imports: [RouterLink],
  templateUrl: './product-detail.component.html',
})
export class ProductDetailComponent {
  private route = inject(ActivatedRoute);
  protected readonly product = this.route.snapshot.data['product'] as Product;
}

テンプレートでは product.nameproduct.price のようなフィールドをそのままバインドします。product は常に埋まっているので、オプショナルチェイニング (?.) もローディング分岐も不要です。Resolver の本質的な魅力がここで現れます — コンポーネントがデータを疑わずに始められる という点です。

Product 追加 — Reactive Forms #

作成画面は空のフォームから始まります。中級 #1 で扱った Reactive Forms のパターンをそのまま持ってきます。

src/app/pages/product-create.component.ts
@Component({
  selector: 'app-product-create',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './product-create.component.html',
})
export class ProductCreateComponent {
  private fb = inject(FormBuilder);
  private products = inject(ProductService);
  private router = inject(Router);

  protected submitting = signal(false);
  protected errorMessage = signal<string | null>(null);

  protected form = this.fb.nonNullable.group({
    name: ['', [Validators.required, Validators.maxLength(80)]],
    price: [0, [Validators.required, Validators.min(0)]],
    category: ['goods' as 'food' | 'drink' | 'goods', Validators.required],
    description: ['', Validators.maxLength(500)],
  });

  onSubmit() {
    if (this.form.invalid) { this.form.markAllAsTouched(); return; }

    this.submitting.set(true);
    this.errorMessage.set(null);

    this.products.create(this.form.getRawValue()).subscribe({
      next: created => this.router.navigate(['/products', created.id]),
      error: () => {
        this.errorMessage.set('保存に失敗しました。もう一度お試しください。');
        this.submitting.set(false);
      },
    });
  }
}

ポイントは 3 つあります。

  • fb.nonNullable.group — すべてのコントロールが null になれないように強制します。getRawValue() の型がきれいになるので、サーバー呼び出しのペイロードとしてそのまま渡せます。
  • markAllAsTouched() — 送信時点ですべてのフィールドを touched 状態にして、抜け漏れがあるフィールドのエラーを一度に表示します。
  • submitting / errorMessage シグナル — 非同期処理の進行状態をコンポーネントが直接持ちます。ローディングスピナーやエラー通知は、すべてこの 2 つのシグナルだけを見て描画します。
src/app/pages/product-create.component.html
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <label>
    名前
    <input formControlName="name" />
    @if (form.controls.name.touched && form.controls.name.invalid) {
      <small class="error">名前を入力してください (80 文字以内)。</small>
    }
  </label>

  <label>価格 <input type="number" formControlName="price" min="0" /></label>

  <label>
    カテゴリ
    <select formControlName="category">
      <option value="goods">雑貨</option>
      <option value="food">食品</option>
      <option value="drink">飲料</option>
    </select>
  </label>

  <label>説明 <textarea formControlName="description" rows="4"></textarea></label>

  @if (errorMessage()) { <p class="error">{{ errorMessage() }}</p> }

  <button type="submit" [disabled]="submitting()">
    {{ submitting() ? '保存中...' : '保存' }}
  </button>
</form>

検証メッセージは touched && invalid のときだけ表示するパターン — 初回レンダリングからいきなり赤いメッセージが並ばないようにする、自然な UX です。

Product 編集 — patchValue でフォームを埋める #

編集画面は作成画面とほぼ同じですが、既存のデータでフォームを埋めて始める ことと、サーバーには 変更されたフィールドだけ を PATCH で送るのが違う点です。ルートと Resolver は詳細画面と同じものを再利用します — path: 'products/:id/edit'resolve: { product: productResolver } を同じく付ければ OK です。

src/app/pages/product-edit.component.ts
export class ProductEditComponent {
  private fb = inject(FormBuilder);
  private route = inject(ActivatedRoute);
  private router = inject(Router);
  private products = inject(ProductService);

  private original = this.route.snapshot.data['product'] as Product;
  protected submitting = signal(false);

  protected form = this.fb.nonNullable.group({
    name: [this.original.name, [Validators.required, Validators.maxLength(80)]],
    price: [this.original.price, [Validators.required, Validators.min(0)]],
    category: [this.original.category, Validators.required],
    description: [this.original.description, Validators.maxLength(500)],
  });

  onSubmit() {
    if (this.form.invalid) { this.form.markAllAsTouched(); return; }

    const patch = this.diff();
    if (Object.keys(patch).length === 0) {
      this.router.navigate(['/products', this.original.id]);
      return;
    }

    this.submitting.set(true);
    this.products.update(this.original.id, patch).subscribe({
      next: () => this.router.navigate(['/products', this.original.id]),
      error: () => this.submitting.set(false),
    });
  }

  private diff(): ProductPatch {
    const current = this.form.getRawValue();
    const result: ProductPatch = {};
    (Object.keys(current) as (keyof typeof current)[]).forEach(key => {
      if (current[key] !== this.original[key]) {
        (result as Record<string, unknown>)[key] = current[key];
      }
    });
    return result;
  }
}

肝心なのは diff() メソッドです。フォームの現在値と元の値をキーごとに比較し、変わったフィールドだけ を集めたオブジェクトを作ります。PATCH 本体が軽くなり、サーバー側でも変更されたフィールドを正確に把握できるので、監査ログや並行制御がきれいになります。変更がまったく無ければサーバーを叩かずに、そのまま詳細画面に戻ります。

注記
フォーム全体の値をそのまま PATCH で送る方法でも動きはします。ただしその場合、「ユーザーが本当に変えた値」と「元の値をそのまま再入力した値」の区別が消えます。変更履歴を残すシステムなら、両者の差は大きいです — 同じ値を再受信してもサーバーは更新したと記録するからです。変更されたフィールドだけ送る習慣をつけておくと、後で大きな助けになります。

テンプレートは作成画面とほぼ同じなので省略します — formControlName を同じくマッチさせるだけで、patchValue なしでも初期値が入ったフォームが立ち上がります。

削除 — confirm ダイアログ #

削除は最も簡単ですが、取り返しのつかない操作 なので、もう一度問う段階が必要です。まずはブラウザ標準の confirm から始めて、後で Material のダイアログに置き換えられます。

詳細コンポーネントに削除メソッドを追加
protected onDelete() {
  if (!confirm(`'${this.product.name}' を削除しますか?`)) return;

  this.products.remove(this.product.id).subscribe({
    next: () => this.router.navigate(['/products']),
    error: () => alert('削除に失敗しました。'),
  });
}

confirm は最も基礎的な形です。実際のプロジェクトであれば MatDialog でモーダルを表示し、一貫したダイアログコンポーネントを作って再利用するほうが良いでしょう — UX の一貫性と自動テストの両面で有利です。

楽観的更新 (Optimistic Update) #

一覧画面から削除する流れを思い浮かべてみましょう。通常は「クリック → DELETE → レスポンス待機 (数百 ms) → 一覧更新」の順です。レスポンスを待つ時間がユーザーには「ボタンを押したのに反応がない」と感じられます。楽観的更新 はこの順番をひっくり返します — 先に画面を変え、後でサーバーに通知し、失敗したら元に戻す。

src/app/pages/product-list.component.ts (楽観的削除)
export class ProductListComponent {
  private products = inject(ProductService);
  protected items = signal<Product[]>([]);
  // ...初期ロードのロジックは省略 (toSignal または effect)

  onDelete(target: Product) {
    if (!confirm(`'${target.name}' を削除しますか?`)) return;

    const snapshot = this.items();
    // 1. 画面を先に変える
    this.items.update(list => list.filter(p => p.id !== target.id));

    // 2. サーバーに知らせる
    this.products.remove(target.id).subscribe({
      error: () => {
        // 3. 失敗時はロールバック
        this.items.set(snapshot);
        alert('削除に失敗しました。画面を元に戻しました。');
      },
    });
  }
}

肝になるのは snapshot 変数 です。変更直前の状態を手元に持っておき、サーバーに拒否されたらそのまま戻します。ユーザーから見て 99% は即座に反応する素早いアプリのように感じられ、1% の失敗だけ短いちらつきとともに元に戻ります。

楽観的更新はすべての操作に向くわけではありません — 決済のように失敗のコストが大きい操作には不適切です。ただし「いいね」・削除・簡単なトグルのような軽い操作では、ユーザーが体感する速度を大きく押し上げます。

まとめ #

今回は Product CRUD 画面を最初から最後まで作りながら、道具を 1 か所に集めてみました。

  • ProductServiceinject(HttpClient) で 4 つのメソッド。すべて Observable を返す
  • 一覧 — toSignal で Observable を Signal に変換し、ローディング・エラー・空状態・正常の 4 分岐
  • 詳細 — productResolver でデータを先に受け取り、初回レンダリングから手元に持って始める
  • 作成 — fb.nonNullable.group でフォームモデル、markAllAsTouched で送信時点の検証
  • 編集 — Resolver で受け取った元データをフォームの初期値にし、diff() で変わったフィールドだけ PATCH
  • 削除 — confirm → DELETE → 一覧更新
  • 楽観的更新 — 画面を先に変え、失敗時は snapshot でロールバック

ここまで作ると、1 つだけぎこちなさが残ります。一覧・詳細・編集の各画面が自分のデータを持っているため、ある画面で更新した値が他の画面に自動で反映されません。編集後に一覧に戻ると、再 fetch するか手動で更新する必要があります。これをきれいに解決する道が 状態管理 です。

次回「Angular実践 #4 状態管理 — Signals Store と NgRx」では、Signals ベースの軽量ストアから始めて、本格的な NgRx まで — どの規模で何を使うかを一気に整理します。本講座のクライアントアーキテクチャ編が本格的に始まる回です。

X