前面的狀態管理,我們有提到了 Reactive Forms 的 API,現在讓我們進一步的討論關於 Reactive Forms 的部分。
「我們要如何設計表單,讓使用者正確、有效率地輸入資料?」
在 Angular 裡,表單(Forms)是與狀態緊密相關的 UI 元件。
簡單的場景,我們可能只需要單層表單(flat form),就能完成輸入。
但在更複雜的系統中,資料往往帶有結構性(例如:一個使用者有多個地址,一張訂單有多筆明細、一個活動有多個會員),這時就需要 巢狀表單(nested form) 來對應資料模型。
今天會先簡單的介紹最好懂的單層表單,逐步走到巢狀表單,並介紹常見的組合模式以及資料帶入的部分。
單層表單的特徵是「一個表單對應一個資料物件」,沒有更深的層級。這種設計適合欄位數量少,且邏輯相對單純的場景。
form = this.fb.group({
username: ['amy_lin'],
password: ['123456']
});
form.value
{ "username": "amy_lin", "password": "123456" }
巢狀表單的好處是可以貼近資料模型結構,方便直接與後端 API 對接。
form = this.fb.group({
name: ['王小明'],
contact: this.fb.group({
email: ['xiaoming@example.com'],
phone: ['0912-345-678']
})
});
form.value
{
"name": "王小明",
"contact": { "email": "xiaoming@example.com", "phone": "0912-345-678" }
}
在實務中,巢狀表單會出現各種組合。以下我們整理出四種最常見的結構,並透過程式碼展示。
這樣的結構適合有「多層次資訊」的場景,例如專案管理、活動申請等。
form = this.fb.group({
activity: this.fb.group({
title: ['暑期夏令營'],
date: ['2025-08-15'],
budget: this.fb.group({ estimated: [100000], actual: [95000] }),
participants: this.fb.group({ leader: ['王老師'], assistant: ['李助教'] })
})
});
輸出結果(form.value
)
{
"activity": {
"title": "暑期夏令營",
"date": "2025-08-15",
"budget": {
"estimated": 100000,
"actual": 95000
},
"participants": {
"leader": "王老師",
"assistant": "李助教"
}
}
}
FormArray 可以讓我們動態增減表單項目,非常適合「清單型輸入」。
form = this.fb.group({
name: ['林小美'],
addresses: this.fb.array([
// 地址一
this.fb.group({
city: ['台北市'],
district: ['中正區'],
street: ['重慶南路一段100號']
}),
// 地址二
this.fb.group({
city: ['新北市'],
district: ['板橋區'],
street: ['文化路二段88號']
})
])
});
輸出結果(form.value
)
{
"name": "林小美",
"addresses": [
{
"city": "台北市",
"district": "中正區",
"street": "重慶南路一段100號"
},
{
"city": "新北市",
"district": "板橋區",
"street": "文化路二段88號"
}
]
}
這個結構與電商場景最為貼近,能清楚表示「多個商品組成一張訂單」。
form = this.fb.group({
orderItems: this.fb.array([
// 商品一
this.fb.group({
productName: ['iPhone 15'],
quantity: [2],
price: [35000]
}),
// 商品二
this.fb.group({
productName: ['AirPods Pro 2'],
quantity: [1],
price: [7500]
})
])
});
form.value
){
"orderItems": [
{
"productName": "iPhone 15",
"quantity": 2,
"price": 35000
},
{
"productName": "AirPods Pro 2",
"quantity": 1,
"price": 7500
}
]
}
FormArray
→ orderItems
(訂單商品清單)。FormGroup
,對應一個商品。form.value
會自動轉成 JSON-like 結構,不包含 Angular 的控制項 class。// rows: FormArray< FormArray< FormControl<boolean> > >
form = this.fb.group({
rows: this.fb.array([
// 第 1 排(5 個座位)
this.fb.array([
this.fb.control<boolean>(false), // A1
this.fb.control<boolean>(true), // A2 已被預訂
this.fb.control<boolean>(false), // A3
this.fb.control<boolean>(false), // A4
this.fb.control<boolean>(false), // A5
]),
// 第 2 排(5 個座位)
this.fb.array([
this.fb.control<boolean>(false), // B1
this.fb.control<boolean>(false), // B2
this.fb.control<boolean>(false), // B3
this.fb.control<boolean>(true), // B4 已被預訂
this.fb.control<boolean>(false), // B5
]),
])
});
form.value
){
"rows": [
[false, true, false, false, false],
[false, false, false, true, false]
]
}
FormArray
代表「排」(rows)。FormArray
代表「該排的所有座位」。FormControl<boolean>
代表一個座位的狀態(true = 已被選取或預訂
,false = 可用
)。當表單控制項 結構已經建立完成,僅需要將 API 資料帶入現有控制項。
setValue
:嚴格比對,key 與長度都要完全一致。patchValue
:寬鬆比對,只更新存在的 key;陣列索引須存在但可部分更新。const userFromApi = {
name: '王小明',
contact: { email: 'xiaoming@example.com', phone: '0912-555-666' }
};
// 完整帶入
this.form.setValue(userFromApi);
// 局部帶入
this.form.patchValue({ contact: { phone: '0912-000-000' } });
const addressesFromApi = [
{ city: '台北市', district: '中正區', street: '重慶南路100號' },
{ city: '新北市', district: '板橋區', street: '文化路二段88號' }
];
// 完整帶入(長度與 key 對齊)
this.form.get('addresses')!.setValue(addressesFromApi);
// 局部帶入(只改第一筆的 city)
this.form.get('addresses')!.patchValue([{ city: '桃園市' }]);
setValue()
→ 長度不符會拋錯。patchValue()
→ 多餘資料會被忽略,不會自動新增控制項。const addressesFromApi = [
{ city: '台北市', district: '中正區', street: '重慶南路100號' },
{ city: '新北市', district: '板橋區', street: '文化路二段88號' },
{ city: '桃園市', district: '中壢區', street: '中大路300號' }
];
const fa = this.form.get('addresses') as FormArray;
// 先同步結構
fa.clear();
for (const a of addressesFromApi) {
fa.push(this.fb.group({
city: [a.city],
district: [a.district],
street: [a.street]
}));
}
// 再用 setValue/patchValue 更新值
fa.setValue(addressesFromApi);
(this.form.get('rows') as FormArray).at(0).at(1).setValue(true); // 單一座位
(this.form.get('rows') as FormArray).patchValue([[false, true]]); // 批次更新
setValue
vs patchValue
特性 | setValue() |
patchValue() |
---|---|---|
完整性檢查 | 要求完整結構,key/index 不可少 | 允許部分更新,缺少欄位會忽略 |
值更新範圍 | 所有控制項都更新 | 只更新指定的控制項 |
事件觸發 | 所有控制項 + 父層的 valueChanges 、statusChanges 都觸發 |
只有被更新的控制項與父層觸發 |
Validator 執行 | 所有相關控制項都重新驗證 | 只驗證被更新的控制項 |
常見場景 | API 回傳完整資料,表單結構與資料完全一致 | API 回傳部分資料,或只需更新部分欄位 |
當 API 的資料筆數改變,或表單結構尚未建立,需要先同步結構,再帶入值。
常見方法:
clear()
+ push()
setControl()
const addressesFromApi = [
{ city: '台北市', district: '中正區', street: '重慶南路100號' },
{ city: '新北市', district: '板橋區', street: '文化路二段88號' },
{ city: '桃園市', district: '中壢區', street: '中大路300號' }
];
const buildAddressGroup = (a: any) =>
this.fb.group({ city: [a.city], district: [a.district], street: [a.street] });
const fa = this.form.get('addresses') as FormArray;
fa.clear();
for (const a of addressesFromApi) fa.push(buildAddressGroup(a));
面向 | 資料帶入(setValue / patchValue) | 結構同步(push / clear / setControl) |
---|---|---|
目標 | 更新現有控制項的值 | 修改控制項樹的結構 |
是否改變表單結構 | ❌ 不會 | ✅ 會(新增/刪除控制項) |
失敗風險 | setValue 結構不一致會拋錯 |
push/clear 用錯會導致結構與資料不同步 |
適合情境 | API 資料筆數不變 | API 資料筆數有變化,需要重建結構 |
程式碼關鍵字 | setValue() 、patchValue() |
push() 、removeAt() 、clear() 、setControl() |
👉 常見準則:表單結構 ≈ 資料模型
在實務應用上,FormArray 需要先建立好結構,才能正確地使用 setValue
或 patchValue
進行資料帶入。整體來說,單層表單比較適合簡單的場景,而巢狀表單則能更好地對應複雜的資料模型。設計表單時,可以從需求的角度來思考,選擇最能貼近資料結構的方式。至於進一步的進階議題,下一篇將會探討 群組驗證與跨欄位邏輯,讓表單設計更符合實際應用的需求。