Angular実践 #3 フォーム + API で Product CRUD
#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/:id | 1 件取得 |
| PATCH | /api/products/:id | 部分更新 |
| DELETE | /api/products/:id | 削除 |
レスポンスの形はシンプルです。
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 は作成時に送る形 (サーバーが id・createdAt を付ける)、ProductPatch は更新時に送る部分オブジェクトです。型をこのように 3 つに分けておくと、画面ごとに何を扱っているかで迷うことが減ります。
ProductService — 4 つのメソッド #
サービスクラスから作ります。すべてのメソッドは Observable を返し、呼び出し側が必要に応じて subscribe するか toSignal で変換して使います。
@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 で描きます。
@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 です。
<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 パターンがよく合います。画面が描かれた瞬間からデータが手元にあれば、「データなし」分岐を用意する必要がなくなるからです。
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 で繋ぎます。
コンポーネントは ActivatedRoute の data から結果を取り出します。
@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.name、product.price のようなフィールドをそのままバインドします。product は常に埋まっているので、オプショナルチェイニング (?.) もローディング分岐も不要です。Resolver の本質的な魅力がここで現れます — コンポーネントがデータを疑わずに始められる という点です。
Product 追加 — Reactive Forms #
作成画面は空のフォームから始まります。中級 #1 で扱った Reactive Forms のパターンをそのまま持ってきます。
@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 つのシグナルだけを見て描画します。
<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 です。
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 本体が軽くなり、サーバー側でも変更されたフィールドを正確に把握できるので、監査ログや並行制御がきれいになります。変更がまったく無ければサーバーを叩かずに、そのまま詳細画面に戻ります。
テンプレートは作成画面とほぼ同じなので省略します — 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) → 一覧更新」の順です。レスポンスを待つ時間がユーザーには「ボタンを押したのに反応がない」と感じられます。楽観的更新 はこの順番をひっくり返します — 先に画面を変え、後でサーバーに通知し、失敗したら元に戻す。
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 か所に集めてみました。
ProductService—inject(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 まで — どの規模で何を使うかを一気に整理します。本講座のクライアントアーキテクチャ編が本格的に始まる回です。