哈囉,各位邦友們!
前面一路從 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 整合,把這套表單真正運用在真實的使用情境內。
參考資料: