深入指南
表單

把既有表單遷移到 Signal Forms

本指南提供把既有程式碼庫遷移到 Signal Forms 的策略,重點放在與既有反應式表單的互通性。

使用 compatForm 由上而下遷移

有時你可能會想在 Signal Form 中使用既有的反應式 FormControl 實例。這在控制項涉及下列情況時很有用:

  • 複雜的非同步邏輯。
  • 尚未移植的繁複 RxJS 運算子。
  • 與既有第三方函式庫整合。

把 FormControl 整合進 Signal Form

假設有一個既有的 passwordControl,使用專門的 enterprisePasswordValidator。與其重寫驗證器,不如把這個控制項橋接進你的訊號狀態。

我們可以用 compatForm 做到這一點:

import {signal} from '@angular/core';
import {FormControl, Validators} from '@angular/forms';
import {compatForm} from '@angular/forms/signals/compat';

// 1. Existing control with a specialized validator
const passwordControl = new FormControl('', {
  validators: [Validators.required, enterprisePasswordValidator()],
  nonNullable: true,
});

// 2. Wrap it inside your form state signal
const user = signal({
  email: '',
  password: passwordControl, // Nest the existing control directly
});

// 3. Create the form
const f = compatForm(user);

// Access values via the signal tree
console.log(f.email().value()); // Current email value
console.log(f.password().value()); // Current value of passwordControl

// Reactive state is proxied automatically
const isPasswordValid = f.password().valid();
const passwordErrors = f.password().errors(); // Returns CompatValidationError if the existing validator fails

在範本中,繫結底層控制項,使用標準的反應式語法:

<form novalidate>
  <div>
    <label>
      Email:
      <input [formField]="f.email" />
    </label>
  </div>

  <div>
    <label>
      Password:
      <input [formField]="f.password" type="password" />
    </label>

    @if (f.password().touched() && f.password().invalid()) {
      <div class="error-list">
        @for (error of f.password().errors(); track error) {
          <p>{{ errorMessage(error) }}</p>
        }
      </div>
    }
  </div>
</form>

來自既有驗證器的錯誤是 CompatValidationError。它們不會設定 message;驗證器回傳的錯誤物件改以 context 提供:

import {ValidationError} from '@angular/forms/signals';
import {CompatValidationError} from '@angular/forms/signals/compat';

errorMessage(error: ValidationError) {
  if (error instanceof CompatValidationError) {
    return (error.context as {message?: string} | null)?.message ?? error.kind;
  }
  return error.message ?? error.kind;
}

把 FormGroup 整合進 Signal Form

你也可以包裝整個 FormGroup。當表單中某個可重用的子區塊(例如地址區塊)仍由既有的反應式表單管理時,這種情況很常見。

import {signal} from '@angular/core';
import {FormGroup, FormControl, Validators} from '@angular/forms';
import {compatForm} from '@angular/forms/signals/compat';
import {required} from '@angular/forms/signals';

// 1. An existing address group with its own validation logic
const addressGroup = new FormGroup({
  street: new FormControl('123 Angular Way', Validators.required),
  city: new FormControl('Mountain View', Validators.required),
  zip: new FormControl('94043', Validators.required),
});

// 2. Include it in the state like it's a value
const checkoutModel = signal({
  customerName: 'Pirojok the Cat',
  shippingAddress: addressGroup,
});

const f = compatForm(checkoutModel, (p) => {
  required(p.customerName);
});

shippingAddress 欄位在你的 Signal Form 樹中扮演分支的角色。你可以在範本中透過 .control() 存取底層的既有控制項,來繫結這些巢狀控制項:

<form novalidate>
  <h3>Shipping Details</h3>

  <div>
    <label>
      Customer Name:
      <input [formField]="f.customerName" />
    </label>

    @if (f.customerName().touched() && f.customerName().invalid()) {
      <div class="error-list">
        <p>Customer name is required.</p>
      </div>
    }
  </div>

  <fieldset>
    <legend>Address</legend>

    @let street = f.shippingAddress().control().controls.street;
    <div>
      <label>
        Street:
        <input [formControl]="street" />
      </label>
      @if (street.touched && street.invalid) {
        <div class="error-list">
          <p>Street is required</p>
        </div>
      }
    </div>

    @let city = f.shippingAddress().control().controls.city;
    <div>
      <label>
        City:
        <input [formControl]="city" />
      </label>
      @if (city.touched && city.invalid) {
        <div class="error-list">
          <p>City is required</p>
        </div>
      }
    </div>

    @let zip = f.shippingAddress().control().controls.zip;
    <div>
      <label>
        Zip Code:
        <input [formControl]="zip" />
      </label>
      @if (zip.touched && zip.invalid) {
        <div class="error-list">
          <p>Zip Code is required</p>
        </div>
      }
    </div>
  </fieldset>
</form>

存取值

雖然 compatForm 會在 FormControl 層級代理值的存取,但完整的表單值仍保留著控制項本身:

const passwordControl = new FormControl('password' /** ... */);

const user = signal({
  email: '',
  password: passwordControl, // Nest the existing control directly
});

const form = compatForm(user);
form.password().value(); // 'password'
form().value(); // { email: '', password: FormControl}

若你需要整份表單值,且控制項已解除包裝,請使用 @angular/forms/signals/compat 的 extractValue:

import {extractValue} from '@angular/forms/signals/compat';

const formValue = computed(() => extractValue(form)); // {email: '', password: 'password'}

由下而上遷移

把 Signal Form 整合進 FormGroup

你可以使用 SignalFormControl,把以訊號為基礎的表單公開為標準的 FormControl。當你想把表單的葉節點遷移到訊號、同時保留父 FormGroup 結構時,這很有用。

import {Component, signal} from '@angular/core';
import {ReactiveFormsModule, FormGroup} from '@angular/forms';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {required} from '@angular/forms/signals';

@Component({
  // ...
  imports: [ReactiveFormsModule],
})
export class UserProfile {
  // 1. Create a SignalFormControl, use signal form rules.
  emailControl = new SignalFormControl('', (p) => {
    required(p, {message: 'Email is required'});
  });

  // 2. Use it in an existing FormGroup
  form = new FormGroup({
    email: this.emailControl,
  });
}

SignalFormControl 會在 Signal Forms 系統與反應式表單系統之間雙向同步值:

  • Signal -> Reactive:透過 Signal Forms 更新值,會立即更新反應式表單控制項。
// Signal Forms update
this.emailControl.fieldTree().value.set('[email protected]');

// Reactive Forms reflects the change
console.log(this.form.value); // {email: '[email protected]'}
  • Reactive -> Signal:透過父 FormGroup 更新值,會更新 Signal Forms 的狀態。
// Reactive Forms update
this.form.patchValue({email: '[email protected]'});

// Signal Forms reflects the change
console.log(this.emailControl.fieldTree().value()); // '[email protected]'

繫結 SignalFormControl

要在 FormGroup 中使用 SignalFormControl,請把它作為控制項傳入,並在範本中使用 .fieldTree 繫結:

readonly emailControl = new SignalFormControl('', (p) => { required(p); });

readonly form = new FormGroup({
  name: new FormControl('Alice'),
  email: this.emailControl,
});
Prefer
<form [formGroup]="form">
  <!-- Standard control -->
  <input formControlName="name" />

  <!-- Signal control -->
  <input [formField]="emailControl.fieldTree" />
</form>
Avoid
<!-- Avoid: Using formControlName or [formControl] for SignalFormControl -->
<input formControlName="email" />
<input [formControl]="emailControl" />

為什麼 SignalFormControl 接收值而非訊號

在標準的 Signal Forms 中,你是把訊號傳入來建立表單:form(mySignal)。

然而,SignalFormControl 的第一個引數接收的是原始值(例如字串或物件):

// Takes a raw value, not a signal
const userControl = new SignalFormControl({
  email: '[email protected]',
});

SignalFormControl 會在內部建立訊號,以攔截寫入,並觸發反應式表單所預期的同步更新。

你仍然可以透過 .sourceValue 存取內部訊號:

const value = userControl.sourceValue();

停用/啟用控制項

用於變更啟用/停用狀態的命令式 API(例如 enable()、disable()),在 SignalFormControl 中刻意不予支援。這是因為控制項的狀態應該源自訊號狀態與規則。

嘗試呼叫 disable/enable 會擲出錯誤。

Avoid
import {signal, effect} from '@angular/core';
import {SignalFormControl} from '@angular/forms/signals/compat';

export class UserProfile {
  readonly emailControl = new SignalFormControl('');

  readonly isLoading = signal(false);

  constructor() {
    // This will throw an error
    effect(() => {
      if (this.isLoading()) {
        this.emailControl.disable();
      } else {
        this.emailControl.enable();
      }
    });
  }
}

改用 disabled 規則:

Prefer
import {signal} from '@angular/core';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {disabled} from '@angular/forms/signals';

export class UserProfile {
  readonly isLoading = signal(false);

  readonly emailControl = new SignalFormControl('', (p) => {
    // The control becomes disabled whenever isLoading is true
    disabled(p, {when: () => this.isLoading()});
  });

  async saveData() {
    this.isLoading.set(true);
    // ... perform save ...
    this.isLoading.set(false);
  }
}

動態操作

用於新增或移除驗證器的命令式 API(例如 addValidators()、removeValidators()、setValidators()),在 SignalFormControl 中刻意不予支援。

嘗試呼叫這些方法會擲出錯誤。

Avoid
export class UserProfile {
  readonly emailControl = new SignalFormControl('');
  readonly isRequired = signal(false);

  toggleRequired() {
    this.isRequired.update((v) => !v);
    // This will throw an error
    if (this.isRequired()) {
      this.emailControl.addValidators(Validators.required);
    } else {
      this.emailControl.removeValidators(Validators.required);
    }
  }
}

改用 applyWhen 規則,有條件地套用驗證器:

Prefer
import {signal} from '@angular/core';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {applyWhen, required} from '@angular/forms/signals';

export class UserProfile {
  readonly isRequired = signal(false);

  readonly emailControl = new SignalFormControl('', (p) => {
    // The control becomes required whenever isRequired is true
    applyWhen(
      p,
      () => this.isRequired(),
      (p) => {
        required(p);
      },
    );
  });
}

手動選取錯誤

setErrors() 與 markAsPending() 方法不受支援。在 Signal Forms 中,錯誤源自驗證規則與非同步驗證狀態。若你需要回報錯誤,應該透過結構定義中的驗證規則,以宣告方式完成。

自動狀態類別

反應式/範本表單會自動加上類別屬性(例如 .ng-valid 或 .ng-dirty),以便為控制項狀態套用樣式。Signal Forms 不會這樣做。

若你想保留這個行為,可以提供 NG_STATUS_CLASSES 預設集:

import {provideSignalFormsConfig} from '@angular/forms/signals';
import {NG_STATUS_CLASSES} from '@angular/forms/signals/compat';
import {bootstrapApplication} from '@angular/platform-browser';

bootstrapApplication(App, {
  providers: [
    provideSignalFormsConfig({
      classes: NG_STATUS_CLASSES,
    }),
  ],
});

你也可以提供自己的自訂設定,依你的自訂邏輯套用任何你想要的類別:

import {provideSignalFormsConfig} from '@angular/forms/signals';
import {bootstrapApplication} from '@angular/platform-browser';

bootstrapApplication(App, {
  providers: [
    provideSignalFormsConfig({
      classes: {
        'ng-valid': ({state}) => state().valid(),
        'ng-invalid': ({state}) => state().invalid(),
        'ng-touched': ({state}) => state().touched(),
        'ng-dirty': ({state}) => state().dirty(),
      },
    }),
  ],
});

自訂控制項

任何自訂 Signal Form 控制項都可以原封不動地用在反應式(及範本驅動)表單中。這讓你能把既有的 ControlValueAccessor 實作遷移到 FormValueControl/FormCheckboxControl,而不破壞既有的使用方式。

IMPORTANT: 不要在同一個元件上同時實作 ControlValueAccessor 與 FormValueControl/FormCheckboxControl。擇一實作即可。

假設有下列自訂控制項:

import {Component, model} from '@angular/core';
import {FormValueControl} from '@angular/forms/signals';

@Component({
  selector: 'app-basic-input',
  template: `
    <div class="basic-input">
      <input
        type="text"
        [value]="value()"
        (input)="value.set($event.target.value)"
        placeholder="Enter text..."
      />
    </div>
  `,
})
export class BasicInput implements FormValueControl<string> {
  /** The current input value */
  value = model('');
}

你可以像使用原生輸入或以 ControlValueAccessor 為基礎的自訂控制項一樣,把這個自訂控制項用在反應式表單中。例如,假設有這個帶有反應式表單的簡單元件。

import {Component} from '@angular/core';
import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';
import {BasicInput} from './basic-input';

@Component({
  selector: 'app-example',
  template: `
    <form [formGroup]="reactiveFormGroup">
      <app-basic-input formControlName="reactiveControlName" />
    </form>
    <p>Text: {{ reactiveFormGroup.value.reactiveControlName }}</p>
  `,
  imports: [ReactiveFormsModule, BasicInput],
})
export class ExampleComponent {
  readonly reactiveFormGroup = new FormGroup({
    reactiveControlName: new FormControl(''),
  });
}

對自訂 app-basic-input 控制項的任何變更,都會反映在反應式 FormControl 中。