iT邦幫忙

2026 iThome 鐵人賽

DAY 18
2
Modern Web

Angular 22 Signal 進化論系列 第 18 篇

Day 18:Angular Signal Forms 穩定了,跟我們用了這麼久的 Reactive Forms 到底差在哪?

  • 分享至 

  • xImage
  •  

在 Angular 中處理表單,很多專案使用的都是 Reactive Forms。

我們會透過 FormControl 管理單一欄位,用 FormGroup 組合多個欄位;如果欄位數量需要動態增減,則可以使用 FormArray。

例如登入表單可以使用 FormGroup 管理 Email 和 Password:

readonly loginForm = new FormGroup({
  email: new FormControl(''),
  password: new FormControl(''),
});

如果是一組可以動態新增或刪除的電話號碼,就可以使用 FormArray:

readonly phones = new FormArray([
  new FormControl(''),
]);

表單的 Value、驗證結果,以及 dirty、touched 等狀態,都由這套 Form Model 管理。

Angular v22 開始,Signal Forms 已經進入 Stable。對已經使用 Reactive Forms 很久的人來說,第一個問題可能是

Signal Forms 是不是只是把 FormControl 換成 signal()?

兩者的差異不只是在 API 寫法,而是表單資料和欄位狀態的管理方式都不太一樣。

這篇先從 Reactive Forms 原本的做法開始,再看看 Signal Forms 帶來哪些改變。

Reactive Forms 怎麼管理資料

使用 Reactive Forms 時,我們通常會先建立 FormGroup,再替每個欄位建立 FormControl:

import {
  FormControl,
  FormGroup,
  ReactiveFormsModule,
} from '@angular/forms';

readonly loginForm = new FormGroup({
  email: new FormControl(''),
  password: new FormControl(''),
});

Template 再透過 formGroup 和 formControlName 綁定:

<form [formGroup]="loginForm">
  <input
    type="email"
    formControlName="email"
  />

  <input
    type="password"
    formControlName="password"
  />
</form>

目前的表單資料可以從 FormGroup 取得:

this.loginForm.value

如果要修改其中一個欄位,則操作對應的 FormControl:

this.loginForm.controls.email.setValue(
  'antonio@example.com'
);

除了 Value,每個 Control 也會保存欄位狀態:

  • valid / invalid:驗證是否通過
  • dirty:欄位是否曾被修改
  • touched:欄位是否曾取得焦點後離開
  • pending:非同步驗證是否仍在進行
  • errors:目前的驗證錯誤資訊

如果需要監聽欄位變化,可以使用 valueChanges:

this.loginForm.controls.email
  .valueChanges
  .subscribe(value => {
    console.log(value);
  });

Reactive Forms 的 Value 和欄位狀態都由 FormControl、FormGroup 這套 Control Tree 管理,Value 的變化則可以透過 Observable 取得。

Reactive Forms 用 Form Model 管理表單資料

Signal Forms 從資料開始

Signal Forms 會先準備表單要使用的資料:

interface LoginData {
  email: string;
  password: string;
}

readonly loginModel = signal<LoginData>({
  email: '',
  password: '',
});

這時只有一份 Writable Signal,還沒有建立表單。

接著把它交給 form():

readonly loginForm = form(
  this.loginModel
);

form() 會根據 Model 的結構建立對應的 FieldTree:

this.loginForm.email
this.loginForm.password

表單的 Value 仍然保存在原本的 loginModel。

Angular 官方在兩者的比較中,把 Source of Truth 分成:

  • Reactive Forms:FormControl / FormGroup
  • Signal Forms:User-defined Writable Signal Model

Reactive Forms 由 Control 保存和管理 Value;Signal Forms 則把資料放在自己建立的 Writable Signal 中。

FieldTree 建立在這份 Model 上,負責欄位綁定、驗證和表單狀態,不會另外保存一份 Value。

兩者的分工可以整理成:

  • Reactive Forms:Control 管理資料與表單狀態
  • Signal Forms:Model 保存資料,FieldTree 提供表單能力

Reactive Forms 與 Signal Forms 的 Source of Truth

Template 改用 formField

Signal Forms 在 Template 中主要透過 [formField] 綁定欄位:

import {
  form,
  FormField,
} from '@angular/forms/signals';

@Component({
  imports: [FormField],
})
export class LoginComponent {}

Template:

<input
  type="email"
  [formField]="loginForm.email"
/>

<input
  type="password"
  [formField]="loginForm.password"
/>

和 Reactive Forms 相比,不需要再使用 formGroup 搭配 formControlName。

當使用者修改 Email 時,欄位的變化會同步回原本的 Signal Model。

直接讀取:

this.loginModel()

就可以取得目前的資料:

{
  email: 'antonio@example.com',
  password: ''
}

不需要再透過 FormGroup.value 取得表單結果。

Signal Model 經 form() 建立 FieldTree 再綁定 formField 的資料流

Angular Signal Forms 綁定表單欄位 - StackBlitz

Field State 也改成 Signal

Reactive Forms 可以從 Control 取得欄位狀態:

this.loginForm.controls.email.valid
this.loginForm.controls.email.invalid
this.loginForm.controls.email.touched
this.loginForm.controls.email.dirty

Signal Forms 則改成:

this.loginForm.email().valid()
this.loginForm.email().invalid()
this.loginForm.email().touched()
this.loginForm.email().dirty()

錯誤資訊也可以直接取得:

this.loginForm.email().errors()

這些欄位狀態本身都是 Signal,因此可以直接搭配 computed():

readonly showEmailError = computed(
  () =>
    this.loginForm.email().touched() &&
    this.loginForm.email().invalid()
);

Template:

@if (showEmailError()) {
  <p>Email 格式錯誤</p>
}

表單狀態可以直接參與其他 Signal 的計算,不需要另外維護一份同步狀態。

Field State 都是 Signal,可以直接用在 computed()

Reactive Forms 和 Signal 之間的轉換

Reactive Forms 的 valueChanges 是 Observable:

this.loginForm.controls.email.valueChanges

如果後續希望使用 Signal,可以透過 toSignal() 轉換:

readonly email = toSignal(
  this.loginForm.controls.email.valueChanges,
  {
    initialValue:
      this.loginForm.controls.email.value,
  }
);

Angular 其他 Reactive API 之間也會遇到類似情況。

例如 output() 回傳的是 OutputRef:

readonly valueChange = output<string>();

如果要接到 RxJS,可以透過 outputToObservable():

const value$ = outputToObservable(
  this.valueChange
);

後續如果需要 Signal,也可以再交給 toSignal():

readonly value = toSignal(
  outputToObservable(this.valueChange)
);

Angular 專案中可能會看到這些轉換:

  • valueChanges → toSignal()
  • output() → outputToObservable()
  • signal() → toObservable()

當專案裡有越來越多狀態使用 Signal,Reactive Forms 和這些狀態一起使用時,就可能需要透過 Interop API 轉換。

Signal Forms 的資料和欄位狀態原本就是 Signal,例如可以直接根據表單內容和驗證狀態計算按鈕是否可以送出:

readonly canSubmit = computed(
  () =>
    this.loginModel().email.length > 0 &&
    this.loginForm().valid()
);

Reactive Forms 與 Signal 之間的轉換

驗證方式也不太一樣

Reactive Forms 通常把 Validator 設定在 Control 上:

readonly loginForm = new FormGroup({
  email: new FormControl(
    '',
    [
      Validators.required,
      Validators.email,
    ]
  ),
  password: new FormControl(
    '',
    [
      Validators.required,
    ]
  ),
});

建立 FormControl 時,會一起設定這個欄位需要的 Validator。

Signal Forms 則透過 Schema 定義規則:

readonly loginForm = form(
  this.loginModel,
  path => {
    required(path.email, {
      message: 'Email 為必填',
    });

    email(path.email, {
      message: 'Email 格式錯誤',
    });

    required(path.password, {
      message: '密碼為必填',
    });
  }
);

path.email、path.password 用來指定規則要套用在哪個欄位。

除了 Validation,Schema 也可以設定 Disabled、Readonly 等欄位行為。

Validators 掛在 FormControl 與 Schema 對 Field Path 定義規則

型別直接跟著 Model

Reactive Forms 現在已經支援 Typed Forms,所以並不是只有 Signal Forms 才有型別。

Signal Forms 的 FieldTree 會直接根據原本的 Model 結構建立。

例如:

interface LoginData {
  email: string;
  password: string;
}

readonly loginModel = signal<LoginData>({
  email: '',
  password: '',
});

readonly loginForm = form(
  this.loginModel
);

因此:

this.loginForm.email
this.loginForm.password

都會跟著 LoginData 的結構。

如果 Model 改成:

interface LoginData {
  account: {
    email: string;
  };
  password: string;
}

對應的 FieldTree 也會變成:

this.loginForm.account.email
this.loginForm.password

表單欄位會跟著 Model 的結構改變,不需要再另外建立一份對應的 Control Tree。

Signal Forms 不代表要重寫 Reactive Forms

Signal Forms 進入 Stable,不代表既有的 Reactive Forms 都需要換掉。

如果專案已經大量使用 FormGroup、FormControl、FormArray、Validators、valueChanges 和 statusChanges,繼續使用 Reactive Forms 沒有問題。既有的 Custom Control、第三方元件整合和表單程式碼也都可以繼續使用。

如果是新的功能,而且專案本身已經大量使用 Signal,Signal Forms 會比較容易和其他 Signal 狀態整合,也能減少 Observable 和 Signal 之間的轉換。

簡單整理兩者的差異:

Reactive Forms Signal Forms
Source of Truth FormControl / FormGroup Writable Signal Model
表單結構 Control Tree FieldTree
Value Control 保存 Signal Model 保存
Field State Control State Signal
Template 綁定 formControlName、formControl [formField]
驗證方式 Validator 設定在 FormControl Schema 對 Field Path 定義規則
型別 Typed Forms 從 Model 推導
Value 變化 valueChanges 直接讀取 Signal

本日結語

Reactive Forms 已經是一套成熟的表單方案,現有專案如果已經大量使用 FormGroup、FormControl 和相關生態,沒有必要因為 Signal Forms 出現就全部重寫。

Signal Forms 把表單資料放在 Writable Signal 中,再由 form() 根據這份資料建立 FieldTree。對已經大量使用 Signal 的專案來說,表單資料和其他狀態之間會更容易整合。

新的表單功能可以依照專案目前的狀態管理方式評估要使用哪一套。既有功能則可以繼續維持 Reactive Forms,不需要為了統一寫法而特別搬移。

資料來源


上一篇
Day 17:effect() 不是拿來同步 Signal 的,Angular 的 Side Effect 該怎麼寫?
下一篇
Day 19:Signal Forms Cross-field Validation 與欄位更新時機
系列文
Angular 22 Signal 進化論 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言