哈囉,各位邦友們!
前面一路從 Standalone、Signals、HTTP,到昨天的 NgOptimizedImage
,可以說核心功能都穩定下來了。
今天就來開始挑戰 Reactive Forms
,過去我們在 Heroes 頁面用 signal 暫存輸入值,對於簡單互動很方便。但一旦需求變複雜(例如欄位較多、動態驗證、串接後端錯誤),Template-driven Forms 就會顯得吃力,而 Reactive Forms 提供明確的資料結構,是企業專案最常見的做法,我目前接觸到的所有專案也都是如此處理。
ReactiveFormsModule
與 FormBuilder.nonNullable()
,建立表單並改寫新增流程。ng serve
。create()
、update()
、delete()
等 CRUD 能力。一、引入 Reactive Forms 並定義 HeroFormGroup
HeroesComponent 要從 Template-driven 轉到 Reactive Forms,首先補上必要的import與型別定義,並用 FormBuilder.nonNullable()
建立FormControl。
// src/app/heroes/heroes.component.ts
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
// ...existing code...
type HeroRank = '' | 'S' | 'A' | 'B' | 'C';
type HeroFormGroup = FormGroup<{
name: FormControl<string>;
rank: FormControl<HeroRank>;
}>;
@Component({
selector: 'app-heroes',
imports: [
ReactiveFormsModule,
RouterModule,
LoadingSpinner,
MessageBanner,
HeroListItem,
],
templateUrl: './heroes.component.html',
styleUrl: './heroes.component.scss',
})
export class HeroesComponent {
// ...existing code...
private readonly fb = inject(FormBuilder);
// ...existing code...
protected readonly createForm: HeroFormGroup = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.minLength(3)]],
rank: this.fb.nonNullable.control<HeroRank>(''),
});
protected readonly editForm: HeroFormGroup = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.minLength(3)]],
rank: this.fb.nonNullable.control<HeroRank>(''),
});
protected readonly editFormValue = signal<{ name: string; rank: HeroRank }>({ name: '', rank: '' });
// ...existing code...
}
二、重構新增英雄流程
新增功能改成讀取 createForm
的值,並在驗證失敗時直接標記欄位。
// src/app/heroes/heroes.component.ts
export class HeroesComponent {
// ...existing code...
protected addHero() {
if (this.createForm.invalid) {
this.createForm.markAllAsTouched();
return;
}
const { name, rank } = this.createForm.getRawValue();
const payload: Pick<Hero, 'name' | 'rank'> = {
name: name.trim(),
rank: rank || undefined,
};
// ...existing code...
this.heroService
.create(payload)
.pipe(
finalize(() => this.creating.set(false)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: (created) => {
this.feedback.set('新增英雄成功!');
this.createForm.reset({ name: '', rank: '' });
this.selectedId.set(created.id);
},
error: (err) => {
// ...existing code...
},
});
}
// ...existing code...
}
三、編輯區塊調整
選取英雄時用 effect
重設 editForm
,並以 computed
判斷是否有改動,避免送出空操作。
// projects/hero-journey/src/app/heroes/heroes.component.ts
export class HeroesComponent {
// ...existing code...
protected readonly dirtyCompared = computed(() => {
const selected = this.selectedHero();
if (!selected) {
return false;
}
const value = this.editFormValue();
const isDirty = (
value.name.trim() !== selected.name ||
value.rank !== ((selected.rank as HeroRank) ?? '')
);
return isDirty;
});
constructor() {
this.editForm.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
this.editFormValue.set({
name: value.name || '',
rank: (value.rank as HeroRank) ?? '',
});
});
// ...existing code...
effect(() => {
const options = this.formRankOptions();
const control = this.createForm.controls.rank;
const current = control.value;
if (!current) {
return;
}
if (!options.includes(current)) {
const fallback: HeroRank = options[0] ?? '';
control.setValue(fallback, { emitEvent: false });
control.markAsPristine();
control.markAsUntouched();
}
});
effect(() => {
const selected = this.selectedHero();
if (!selected) {
this.editForm.reset({ name: '', rank: '' });
this.editForm.markAsPristine();
this.editForm.markAsUntouched();
this.editFormValue.set({ name: '', rank: '' });
this.saveError.set(null);
return;
}
const formValue = {
name: selected.name,
rank: (selected.rank as HeroRank) ?? '',
};
this.editForm.reset(formValue);
this.editForm.markAsPristine();
this.editForm.markAsUntouched();
this.editFormValue.set(formValue);
this.saveError.set(null);
});
}
protected saveSelected() {
const hero = this.selectedHero();
if (!hero || this.editForm.invalid || !this.dirtyCompared()) {
return;
}
const { name, rank } = this.editForm.getRawValue();
// ...existing code...
}
// ...existing code...
this.heroService
.update(hero.id, { name: name.trim(), rank: rank || undefined })
.pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: (updated) => {
this.feedback.set('更新英雄成功!');
this.selectedId.set(updated.id);
},
error: (err) => {
this.saveError.set(String(err ?? 'Unknown error'));
},
});
}
四、更新範本
<!-- src/app/heroes/heroes.component.html -->
<section class="create" id="create">
<form [formGroup]="createForm" (ngSubmit)="addHero()">
<label for="new-hero">Name:</label>
<input
id="new-hero"
placeholder="enter new hero"
formControlName="name"
type="text" />
@if (createForm.controls.name.touched && createForm.controls.name.invalid) {
<small class="error">請輸入至少 3 個字</small>
}
<label for="new-hero-rank">Rank:</label>
<select id="new-hero-rank" formControlName="rank">
<option [ngValue]="''">未指定</option>
@for (rank of formRankOptions(); track rank) {
<option [ngValue]="rank">{{ rankLabel(rank) }}</option>
}
</select>
<button type="submit" [disabled]="creating() || createForm.invalid">
@if (creating()) { Saving... } @else { Add }
</button>
</form>
@if (createError(); as err) {
<app-message-banner type="error">Create failed: {{ err }}</app-message-banner>
}
</section>
<!-- ...existing code... -->
@if (selectedHero(); as hero) {
<aside class="panel" [formGroup]="editForm">
<h3>Edit</h3>
<p>
#{{ hero.id }} - {{ hero.name }}
@if (hero.rank) { <span class="rank">[{{ hero.rank }}]</span> }
</p>
<label for="hero-name">Name:</label>
<input id="hero-name" type="text" formControlName="name" />
@if (editForm.controls.name.touched && editForm.controls.name.invalid) {
<small class="error">請輸入至少 3 個字</small>
}
<label for="hero-rank">Rank:</label>
<select id="hero-rank" formControlName="rank">
<option [ngValue]="''">未指定</option>
@for (rank of formRankOptions(); track rank) {
<option [ngValue]="rank">{{ rankLabel(rank) }}</option>
}
</select>
<button
type="button"
(click)="saveSelected()"
[disabled]="saving() || editForm.invalid || !dirtyCompared()">
@if (saving()) { Saving... } @else { Save }
</button>
@if (saveError(); as err) {
<app-message-banner type="error">Save failed: {{ err }}</app-message-banner>
}
</aside>
}
說明:
FormGroup
自帶 dirty
, pristine
, touched
等旗標,後續要串續後端錯誤、highlight 欄位都更簡單。驗收清單:
今日小結:
今天我們把新增與編輯流程變成透過 Reactive Forms 去實現。
明天會接著處理更複雜的 UI 整合,把這套表單真正運用在真實的使用情境內。
參考資料: