โหมดมืด
บทที่ 5 — Forms (Template + Reactive)
หลังจบบท คุณจะ:
- เลือกระหว่าง Template-driven vs Reactive forms
- ตรวจสอบข้อมูล (Validate) ทั้งแบบสำเร็จรูปและเขียนเอง
- ทำ dynamic form (FormArray)
- จัดการ form ที่ซับซ้อนด้วย type-safe pattern
ก่อนอ่าน: ควรผ่าน บทที่ 1 (รู้จัก binding
[(ngModel)]+ event) · บางส่วน (§14, §20) ใช้ Signals พื้นฐาน (ดูบทที่ 3) และ RxJS เบื้องต้น (ดูบทที่ 6) — อ่านผ่าน ๆ ก่อนได้ กลับมาทำความเข้าใจลึกขึ้นหลังอ่านบทเหล่านั้น
1. Forms — Template vs Reactive
Angular มีวิธีทำฟอร์ม 2 แบบ — เลือกให้ถูกตั้งแต่ต้นจะประหยัดเวลามาก ความต่างหลักคือ "ตรรกะของฟอร์มอยู่ที่ไหน":
| Template-Driven (TDF) | Reactive Forms | |
|---|---|---|
| ตรรกะฟอร์มอยู่ที่ | HTML template | TypeScript class |
| เครื่องมือหลัก | [(ngModel)] | FormControl / FormGroup / FormArray |
| เหมาะกับ | ฟอร์มง่าย ๆ สั้น ๆ | ฟอร์มซับซ้อน / dynamic |
| Type-safe + test | ทำได้ยากกว่า | ✅ ดีกว่า (มี type ชัดเจน) |
| นิยมใน enterprise (แอปองค์กร/ทีมใหญ่) | น้อย | ✅ มาตรฐาน |
📝 Type-safe (ปลอดภัยเรื่องชนิดข้อมูล) = compiler (ตัวแปล TypeScript — แปลงโค้ดเป็นโปรแกรม) ตรวจชนิดข้อมูลให้ตอนเขียน จับ bug (ข้อผิดพลาดในโค้ด) ตั้งแต่ตอน compile ไม่ใช่รอเจอตอนรัน
🔍 เปรียบเทียบ: ฟอร์มคือ "แบบฟอร์มกระดาษ" ที่ต้องตรวจว่ากรอกครบ/ถูกไหม · TDF = เขียนกฎตรวจไว้บนตัวกระดาษเลย (ใน HTML) เหมาะฟอร์มสั้น · Reactive = มี "เจ้าหน้าที่" (โค้ด TS) ถือกฎไว้ตรวจ แยกจากตัวกระดาษ ไม่ปนกับ HTML · แบบนี้จัดการฟอร์มยาว ๆ ที่มีเงื่อนไขซับซ้อนได้ดีกว่า และเทสต์ง่ายกว่าเพราะตรรกะทั้งหมดอยู่ในโค้ด ไม่ต้องไปจำลอง HTML
→ เล่มนี้เน้น Reactive Forms เพราะเป็นมาตรฐานของทีมใหญ่ — TDF อธิบายพอให้รู้จัก (ข้อ 2) แล้วลุย Reactive เป็นหลัก
📖 สัญลักษณ์ template ที่จะเจอในบทนี้ (กางก่อน):
[prop]="x"= one-way binding ส่งค่าเข้า property → component (ดูเพิ่มเติม บทที่ 1)(event)="fn()"= event binding จับ event จาก template (เช่น คลิก, พิมพ์)[(ngModel)]="x"= two-way binding —[()]รูปร่างเหมือนกล้วยอยู่ในกล้อง จึงมีชื่อเล่นว่า "banana in a box" — ส่งค่าไปมาระหว่าง input กับ TypeScript ได้ทั้งสองทาง → ต้องimports: [FormsModule]ใน component#myInput= template reference variable — เก็บ reference (ตัวชี้) ของ element ไว้ใช้ใน template เดียวกัน#f="ngForm"= bind reference เป็น instance ของ directivengForm(ไม่ใช่แค่ DOM element ปกติ) —ngFormถูกแปะให้อัตโนมัติกับทุก<form>ที่อยู่ใน scope ของFormsModuleไม่ต้องเขียน directive เองแบบที่เคยเห็นในบทที่ 1
2. Template-Driven Forms (Brief)
แบบ TDF เขียนตรรกะฟอร์มไว้ใน HTML ผ่าน [(ngModel)] + validation directive (required, email) — เร็วดีกับฟอร์มสั้น ๆ แต่พอฟอร์มใหญ่จะดูแลยาก ดูพอรู้จักแล้วไปเน้น Reactive:
typescript
import { FormsModule, NgForm } from '@angular/forms';
// NgForm = type ของ object ฟอร์ม TDF ที่ Angular สร้างให้อัตโนมัติ
// หมายเหตุ: standalone: true เป็น default ตั้งแต่ Angular 17 ไม่ต้องเขียนก็ได้ แต่ใส่ไว้ให้ชัดเจน
@Component({
standalone: true,
imports: [FormsModule],
template: `
<!-- #f="ngForm" = เก็บ reference ของฟอร์มทั้งก้อนไว้ใช้ใน template (ดู callout สัญลักษณ์ข้างต้น) -->
<form #f="ngForm" (ngSubmit)="onSubmit(f)">
<input
name="email"
[(ngModel)]="user.email"
required
email
#emailInput="ngModel"
>
<!-- #emailInput="ngModel" = เก็บ reference ของ ngModel directive ของช่องนี้ ใช้ตรวจ valid/touched -->
@if (emailInput.invalid && emailInput.touched) {
<p class="error">Invalid email</p>
}
<input name="name" [(ngModel)]="user.name" required>
<button type="submit" [disabled]="f.invalid">Submit</button>
</form>
`
})
export class UserFormComponent {
user = { email: '', name: '' };
onSubmit(form: NgForm) {
console.log(form.value);
}
}→ ใช้ [(ngModel)] + validation directive
→ เร็วดีตอนทำ prototype (ต้นแบบ) — แต่ไม่ scale ดีกับฟอร์มใหญ่
3. Reactive Forms — Setup
แบบ Reactive สร้างโครงฟอร์มใน TypeScript ผ่าน FormGroup (กลุ่มของฟอร์มทั้งหมด) ที่ประกอบด้วย FormControl แต่ละตัว (แต่ละช่อง input) — แล้วผูกเข้ากับ HTML template ผ่าน attribute [formGroup]="form" (ที่ tag <form>) และ formControlName="ชื่อช่อง" (ที่แต่ละ <input>) · FormBuilder (fb) คือ service ช่วยให้เขียนโค้ดสั้นลงโดยไม่ต้อง new FormControl() ทุกตัว:
typescript
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email">
<input formControlName="name">
<button type="submit" [disabled]="form.invalid">Submit</button>
</form>
`
})
export class UserFormComponent {
// inject() = วิธีขอ service เข้ามาใช้ (ดูบท 3)
private fb = inject(FormBuilder); // FormBuilder = service ช่วยสร้างฟอร์มแบบสั้น
form = this.fb.group({
// syntax array: [ค่าเริ่มต้น, validator (เดี่ยวหรือ array), async-validator (optional)]
// เช่น ['', Validators.required] หรือ ['', [Validators.required, Validators.email]]
email: ['', [Validators.required, Validators.email]],
name: ['', Validators.required]
});
onSubmit() {
if (this.form.invalid) return;
console.log(this.form.value);
}
}→ ฟอร์มทั้งหมดอยู่ใน TypeScript — test + ดูแลรักษาง่ายกว่า
4. FormControl, FormGroup, FormArray
Reactive form ประกอบจาก 3 บล็อก: FormControl (1 ช่อง input), FormGroup (กลุ่มของ control = ทั้งฟอร์ม), FormArray (รายการที่เพิ่ม/ลบได้ เช่น เบอร์โทรหลายเบอร์) · เข้าใจ 3 ตัวนี้แล้วประกอบฟอร์มอะไรก็ได้:
FormControl — Single Input
typescript
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
email = new FormControl('', [Validators.required, Validators.email]);
// ผูกกับ HTML template: <input [formControl]="email">
// (ต้อง imports: [ReactiveFormsModule] ใน component และใช้ [formControl]="..." ไม่ใช่ formControlName)
// อ่านค่า
email.value // ค่าปัจจุบัน
email.valid // ผ่าน validation ไหม?
email.invalid
email.errors // { required: true } | { email: true } | null
email.touched // ผู้ใช้เคยแตะ (คลิกออก) ไหม?
email.dirty // ผู้ใช้เคยพิมพ์ไหม?
email.pristine // ยังไม่เคยแก้
email.disabled
// เขียนค่า
email.setValue('a@b.com');
email.reset();
email.disable();
email.enable();
email.markAsTouched();
email.markAsDirty();FormGroup — Group of Controls
typescript
import { FormGroup, FormControl, Validators } from '@angular/forms';
form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
name: new FormControl('', Validators.required),
age: new FormControl(0)
});
// เข้าถึงค่า
form.value // { email: '...', name: '...', age: 0 }
form.get('email')?.value
form.get('email')?.valid
form.controls.email.value // เข้าถึงแบบมี type
// แก้ค่า
form.patchValue({ email: 'a@b.com' }) // แก้บางช่อง (partial)
form.setValue({ email: '...', name: '...', age: 0 }) // ตั้งค่าครบทุกช่อง
form.reset()FormBuilder — Less Boilerplate
typescript
private fb = inject(FormBuilder);
// กระชับกว่า
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
name: ['', Validators.required],
age: [0]
});
// เทียบเท่ากับ:
form = new FormGroup({
email: new FormControl('', { ... }),
...
});FormArray — Dynamic List
typescript
form = this.fb.group({
// fb.array() = สร้าง FormArray (รายการ control)
// fb.control() = สร้าง FormControl เดี่ยว ๆ (1 ช่อง)
hobbies: this.fb.array([
this.fb.control('Reading'),
this.fb.control('Coding')
])
});
// เข้าถึง
get hobbies() {
return this.form.get('hobbies') as FormArray;
}
addHobby() {
this.hobbies.push(this.fb.control(''));
}
removeHobby(index: number) {
this.hobbies.removeAt(index);
}html
<div formArrayName="hobbies">
@for (hobby of hobbies.controls; track $index; let i = $index) {
<input [formControlName]="i">
<button type="button" (click)="removeHobby(i)">×</button>
}
</div>
<button type="button" (click)="addHobby()">+ Add</button>5. Validation — Built-in
Angular มี validator สำเร็จรูปให้ใส่กับแต่ละ control — บังคับกรอก (required), รูปแบบอีเมล (email), ความยาว (minLength/maxLength), ช่วงตัวเลข (min/max), regex (pattern) · ใส่เป็น array ได้หลายตัวพร้อมกัน:
typescript
form = this.fb.group({
email: ['', [
Validators.required,
Validators.email
]],
name: ['', [
Validators.required,
Validators.minLength(2),
Validators.maxLength(50)
]],
age: [null, [
Validators.required,
Validators.min(18),
Validators.max(120)
]],
website: ['', [
Validators.pattern(/^https?:\/\/.+/)
]]
});Common Validators
ts
Validators.required // บังคับกรอก
Validators.requiredTrue // สำหรับ checkbox (ต้องติ๊ก)
Validators.email // ต้องเป็นรูปแบบอีเมล
Validators.min(n) // ค่าต่ำสุด
Validators.max(n) // ค่าสูงสุด
Validators.minLength(n) // ความยาวขั้นต่ำ
Validators.maxLength(n) // ความยาวสูงสุด
Validators.pattern(/regex/) // ตรงกับ regex
Validators.nullValidator // ไม่ทำอะไร — ใช้เป็น placeholder เมื่อยังไม่อยากใส่ validator จริง6. Custom Validator
เมื่อ validator สำเร็จรูปไม่พอ เขียนเองได้ — มันคือฟังก์ชันที่รับ control คืน null (ผ่าน) หรือ object error (ไม่ผ่าน) · มีหลายแบบ: ธรรมดา, แบบรับ config (factory), async (เช็คกับเซิร์ฟเวอร์), และข้ามหลายช่อง (เช่น password ตรงกันไหม):
Simple Validator
typescript
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function noWhitespace(control: AbstractControl): ValidationErrors | null {
const value = control.value as string;
return value && value.trim() !== value
? { whitespace: true }
: null;
}
// วิธีใช้
name: ['', [Validators.required, noWhitespace]]Validator Factory (with config)
typescript
export function minWords(min: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value as string;
if (!value) return null;
const count = value.trim().split(/\s+/).length;
return count < min
? { minWords: { required: min, actual: count } }
: null;
};
}
// วิธีใช้
description: ['', [minWords(5)]]Async Validator
🔴 อย่าใส่
debounceTimeใน validator — มันไม่ทำงาน!สรุปสั้น (ไทยล้วน): ถ้าใช้ async validator ที่ยิง API อย่าหวังว่า
debounceTimeจะหน่วงให้ — มันไม่ทำงาน · ทางแก้ง่ายสุดคือใส่updateOn: 'blur'(ตรวจตอนผู้ใช้คลิกออกจากช่อง แทนที่จะตรวจทุกครั้งที่พิมพ์) หรือ debounce ที่ระดับvalueChangesด้วย custom strategy (ดูบท 6)เหตุผลทางเทคนิค (อ่านถ้าอยากรู้ลึก — ต้องรู้ RxJS พื้นฐานจากบท 6 ก่อนถึงจะเข้าใจเต็ม ๆ):
of(control.value)emit (ปล่อยค่าออก) ครั้งเดียวแล้วจบทันที- เพราะงั้น debounce (หน่วงรอนิ่ง) จึงทำอะไรไม่ได้ — ไม่มีสตรีมให้หน่วง
- Angular เรียก validator ใหม่ทุกครั้งที่ value เปลี่ยน จึงยิง API ทุก keystroke (การกดแป้นแต่ละครั้ง)
ถ้ายังไม่รู้จัก RxJS/
of()ข้ามส่วนนี้ไปก่อนได้ กลับมาอ่านหลังเรียนบทที่ 6
💡 HttpClient = ตัวเรียก API (จะสอนละเอียดในบทที่ 6) — ก่อน inject ได้ต้องเพิ่ม
provideHttpClient()ในapp.config.tsก่อน ตอนนี้ดูแค่ pattern ของ async validator ก็พอ
typescript
import { AsyncValidatorFn } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { map, catchError, of } from 'rxjs';
export function uniqueEmail(http: HttpClient): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) return of(null);
// ✅ ไม่ใส่ debounceTime — ใช้ updateOn: 'blur' แทน
// ✅ ใช้ HttpParams (type-safe + encode ให้) ดีกว่า template-string ลง URL
return http.get<boolean>('/api/check-email', {
params: new HttpParams().set('email', control.value)
}).pipe(
map(exists => exists ? { emailExists: true } : null),
// ✅ ถ้า network/server พัง → ถือว่าผ่าน (null) ไม่ให้ฟอร์มค้างที่ PENDING ตลอด
catchError(() => of(null))
);
};
}
// วิธีใช้
email: ['', {
validators: [Validators.required, Validators.email],
asyncValidators: [uniqueEmail(this.http)],
updateOn: 'blur' // ✅ ตรวจตอนออกจากช่อง → ไม่ต้อง debounce
}]Cross-field Validator
typescript
// ตรวจที่ระดับ FormGroup (ข้ามหลายช่อง)
export const passwordMatch: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
};
// วิธีใช้
form = this.fb.group({
password: ['', Validators.required],
confirmPassword: ['', Validators.required]
}, { validators: [passwordMatch] });7. Display Validation Errors
validator บอกว่าผิด แต่ต้อง "แสดงให้ผู้ใช้เห็น" ด้วย — เคล็ดลับ UX: โชว์ error เฉพาะเมื่อ control invalid และ touched (ผู้ใช้แตะแล้ว) ไม่งั้นจะขึ้น error แดงตั้งแต่ยังไม่ทันพิมพ์ · แนะนำทำ helper method ลดโค้ดซ้ำ:
html
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<label>Email</label>
<input formControlName="email">
@if (form.get('email')?.invalid && form.get('email')?.touched) {
<div class="errors">
@if (form.get('email')?.errors?.['required']) {
<p>Email is required</p>
}
@if (form.get('email')?.errors?.['email']) {
<p>Invalid email format</p>
}
</div>
}
</div>
<button type="submit" [disabled]="form.invalid">Submit</button>
</form>Helper Methods
typescript
@Component({...})
export class UserFormComponent {
form = this.fb.group({...});
isInvalid(name: string): boolean {
const ctrl = this.form.get(name);
return !!(ctrl?.invalid && ctrl?.touched);
}
getError(name: string): string | null {
const ctrl = this.form.get(name);
if (!ctrl?.errors || !ctrl?.touched) return null;
const errors = ctrl.errors;
if (errors['required']) return 'Required';
if (errors['email']) return 'Invalid email';
// error key คือ 'minlength' (lowercase ทั้งหมด) ไม่ใช่ 'minLength' — ระวังสับสนกับชื่อ Validators.minLength()
if (errors['minlength']) return `Min ${errors['minlength'].requiredLength} chars`;
return 'Invalid';
}
}html
<input formControlName="email">
@if (isInvalid('email')) {
<p class="error">{{ getError('email') }}</p>
}8. Typed Forms (Angular 14+)
ตั้งแต่ Angular 14 ฟอร์มมี type จริง — form.value.name รู้ว่าเป็น string ไม่ใช่ any ช่วยจับ bug ตอนเขียนและได้ autocomplete (editor แนะนำชื่อ field ให้อัตโนมัติ) · ปกติ FormControl จะมี type เป็น T | null เสมอ (เช่น string | null) เพราะ reset() ทำให้ค่าเป็น null — ใช้ nonNullable.group() เพื่อให้ reset() คืนค่าเริ่มต้นแทน null:
typescript
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
interface UserForm {
name: FormControl<string>;
email: FormControl<string>;
age: FormControl<number>; // ใช้ number (ไม่ใช่ number | null) เพราะอยู่ใน nonNullable group
}
private fb = inject(FormBuilder);
// ⚠️ จุดพลาดสำคัญ: `nonNullable.group({...})` ห้ามมี control ที่อาจเป็น null
// (เช่น `fb.control<number | null>(null)`) — TypeScript จะแสดง error ขีดเส้นแดงใต้โค้ดตอนเขียน เพราะชนิดไม่ตรง
// ทุก control ต้องเป็น non-nullable ทั้งหมด (มีค่าเริ่มต้นเสมอ ไม่ใช่ null)
form: FormGroup<UserForm> = this.fb.nonNullable.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
age: [0, Validators.min(0)] // ✅ มีค่าเริ่มต้น 0 (ไม่ใช่ null)
});
// ถ้าอยากให้ age เป็น nullable จริง ๆ ต้องใช้ this.fb.group(...) ธรรมดา (ไม่ใช่ nonNullable):
// form = this.fb.group({
// name: this.fb.nonNullable.control(''),
// age: this.fb.control<number | null>(null)
// });
// ตอนนี้มี type แล้ว!
const name = this.form.value.name; // string
const email = this.form.value.email; // string
const age = this.form.value.age; // number
this.form.patchValue({ name: 'Anna' }); // ตรวจ type ให้→ autocomplete + จับ error ตั้งแต่ตอน compile
NonNullable Form (ค่าไม่เป็น null)
typescript
// ทุก control ไม่เป็น null (ไม่คืน undefined)
form = this.fb.nonNullable.group({
name: '', // string (ไม่ใช่ string | null)
email: ''
});
this.form.value.name // string (ไม่ใช่ string | null)9. Form Submission
ตอน submit มี 3 จังหวะสำคัญ: markAllAsTouched() (โชว์ error ทุกช่องถ้ายังไม่แตะ), เช็ค form.invalid ก่อนส่ง, และจัดการสถานะ submitting/error · ใช้ getRawValue() เพื่อดึงค่ารวมช่องที่ disable ด้วย:
typescript
@Component({...})
export class UserFormComponent {
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
name: ['', Validators.required]
});
submitting = signal(false);
error = signal<string | null>(null);
async onSubmit() {
// ทำให้ทุกช่องเป็น touched เพื่อโชว์ error ที่ค้างอยู่
this.form.markAllAsTouched();
if (this.form.invalid) return;
this.submitting.set(true);
this.error.set(null);
try {
const data = this.form.getRawValue(); // ดึงค่าปัจจุบัน (รวมช่อง disable)
await this.userService.create(data);
this.form.reset();
} catch (e) {
this.error.set('Failed to save');
} finally {
this.submitting.set(false);
}
}
}html
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email">
<input formControlName="name">
@if (error()) {
<p class="error">{{ error() }}</p>
}
<button type="submit" [disabled]="form.invalid || submitting()">
{{ submitting() ? 'Saving...' : 'Save' }}
</button>
</form>10. Nested Form Group
ฟอร์มจริงมักมีกลุ่มย่อย เช่น "ที่อยู่" ที่มีหลายช่อง — จัดเป็น FormGroup ซ้อนใน group หลักได้ ทำให้โครงข้อมูลเป็นระเบียบ (ผูกในเทมเพลตด้วย formGroupName):
typescript
form = this.fb.group({
name: [''],
email: [''],
address: this.fb.group({
street: [''],
city: [''],
country: ['']
}),
preferences: this.fb.group({
newsletter: [false],
notifications: [true]
})
});html
<form [formGroup]="form">
<input formControlName="name">
<input formControlName="email">
<div formGroupName="address">
<input formControlName="street">
<input formControlName="city">
<input formControlName="country">
</div>
<div formGroupName="preferences">
<label>
<input type="checkbox" formControlName="newsletter">
Subscribe to newsletter
</label>
</div>
</form>Access Nested
typescript
const street = this.form.get('address.street')?.value;
const street = this.form.value.address?.street;
this.form.patchValue({
address: { city: 'Bangkok' } // แก้บางช่อง — ช่องอื่นไม่เปลี่ยน
});11. Dynamic Forms — FormArray
เมื่อจำนวนช่องไม่แน่นอน (เพิ่ม/ลบ skill, การศึกษา, ที่อยู่ได้หลายอัน) ใช้ FormArray — push/removeAt เพื่อเพิ่มลบ control ตอน runtime · เก็บได้ทั้ง control เดี่ยวหรือ group:
Multi-skill Input
typescript
@Component({
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name">
<div formArrayName="skills">
<h3>Skills</h3>
@for (skill of skills.controls; track $index; let i = $index) {
<div>
<input [formControlName]="i" placeholder="Skill">
<button type="button" (click)="removeSkill(i)">×</button>
</div>
}
<button type="button" (click)="addSkill()">+ Add Skill</button>
</div>
<button type="submit">Save</button>
</form>
`
})
export class FormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
name: ['', Validators.required],
skills: this.fb.array<FormControl<string>>([])
});
get skills() {
return this.form.controls.skills;
}
addSkill() {
this.skills.push(this.fb.nonNullable.control('', Validators.required));
}
removeSkill(index: number) {
this.skills.removeAt(index);
}
}Array of FormGroup
typescript
form = this.fb.group({
name: [''],
educations: this.fb.array([
this.fb.group({
school: [''],
degree: [''],
year: [0]
})
])
});
get educations() {
// as FormArray = cast type เพราะตัวอย่างนี้ใช้ fb.group() แบบ untyped (ไม่ใช่ nonNullable)
// ถ้าใช้ typed form ที่กำหนด interface ไว้ชัดเจน ไม่จำเป็นต้อง cast
return this.form.controls.educations as FormArray;
}
addEducation() {
this.educations.push(this.fb.group({
school: ['', Validators.required],
degree: [''],
year: [new Date().getFullYear()]
}));
}html
<div formArrayName="educations">
@for (edu of educations.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input formControlName="school">
<input formControlName="degree">
<input formControlName="year" type="number">
<button type="button" (click)="removeEducation(i)">×</button>
</div>
}
</div>
<button type="button" (click)="addEducation()">+ Add Education</button>12. Custom Form Controls
อยากให้ component ของเราเอง (เช่น color picker, star rating) ใช้กับ formControlName ได้เหมือน input ปกติ — ทำได้โดย implement ControlValueAccessor (สะพานเชื่อม component กับระบบฟอร์ม) · ส่วนนี้เป็นเนื้อหาขั้นสูง — ถ้าเพิ่งเริ่มหัด ข้ามไปก่อนได้ กลับมาอ่านเมื่อต้องสร้าง custom input component
ทำให้ component ใด ๆ ใช้กับฟอร์มได้:
typescript
import { Component, forwardRef } from '@angular/core';
// forwardRef = อ้างอิง class ตัวเอง (ColorPickerComponent) ขณะที่ยังประกาศไม่เสร็จ
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-color-picker',
standalone: true,
template: `
<div class="picker">
@for (color of colors; track color) {
<div
class="color"
[style.background]="color"
[class.selected]="value === color"
(click)="select(color)"
></div>
}
</div>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR, // ลงทะเบียน component นี้ให้ Angular รู้จักว่าใช้กับฟอร์มได้
useExisting: forwardRef(() => ColorPickerComponent), // อ้างถึง class ที่กำลังสร้างอยู่ (ยังสร้างไม่เสร็จ)
multi: true // บอกว่ามี provider หลายตัวสำหรับ token นี้ได้
}
]
})
export class ColorPickerComponent implements ControlValueAccessor {
colors = ['red', 'green', 'blue', 'yellow'];
value = '';
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
select(color: string) {
this.value = color;
this.onChange(color);
this.onTouched();
}
writeValue(value: string): void {
this.value = value;
}
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
}html
<!-- ใช้ได้เหมือน input ปกติ -->
<app-color-picker formControlName="favoriteColor"></app-color-picker>13. Form Reset
reset() ล้างค่าฟอร์ม (ใส่ค่าเริ่มต้นใหม่ก็ได้) · หลัง submit สำเร็จมักต้อง reset + markAsPristine()/markAsUntouched() เพื่อล้างสถานะ "เคยแตะ/เคยแก้" ไม่ให้ error ค้าง:
typescript
this.form.reset(); // ล้างเป็นค่าว่างหมด
this.form.reset({ name: 'Anna', email: '' }); // reset พร้อมตั้งค่าใหม่
this.form.reset({ name: 'Anna' }, { emitEvent: false }); // ไม่ปล่อย event (เงียบ)After Submission
typescript
async onSubmit() {
if (this.form.invalid) return;
await this.api.save(this.form.getRawValue());
// reset ฟอร์ม + ล้างสถานะ (เคยแก้/เคยแตะ)
this.form.reset();
this.form.markAsPristine();
this.form.markAsUntouched();
}14. valueChanges + statusChanges
ฟอร์มปล่อย Observable เมื่อค่า/สถานะเปลี่ยน — valueChanges (ค่าเปลี่ยน), statusChanges (valid/invalid เปลี่ยน) · ใช้ทำ auto-save, ค้นหาแบบ debounce, หรือแปลงเป็น signal ด้วย toSignal:
typescript
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// subscribe ฟังการเปลี่ยนค่า — ✅ ใส่ takeUntilDestroyed() เพื่อกัน memory leak
// (จะ unsubscribe ให้อัตโนมัติเมื่อ component ถูกทำลาย — ดูบท 6)
// หมายเหตุ: ต้องเรียกใน injection context เช่น constructor หรือ field initializer
constructor() {
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(value => {
console.log('form changed:', value);
});
this.form.controls.email.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(email => {
console.log('email:', email);
});
// ฟังการเปลี่ยนสถานะ (VALID, INVALID, PENDING, DISABLED)
this.form.statusChanges
.pipe(takeUntilDestroyed())
.subscribe(status => {
console.log('status:', status);
});
}Convert to Signal
typescript
import { toSignal } from '@angular/core/rxjs-interop';
// signal ของค่าฟอร์ม
formValue = toSignal(this.form.valueChanges, {
initialValue: this.form.value
});
// ใช้ใน computed
isModified = computed(() =>
JSON.stringify(this.formValue()) !== JSON.stringify(this.initialValue)
);Debounced Search
typescript
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
// debounceTime(300) = รอนิ่ง 300ms ก่อนส่ง ไม่ส่งทุกตัวอักษร
// distinctUntilChanged() = ไม่ส่งถ้าค่าเหมือนเดิม
// switchMap = ยกเลิก request เก่า ถ้ามี request ใหม่เข้ามา (ดูบท 6 สำหรับ RxJS operators)
// ภายใน class:
private api = inject(SearchApiService); // inject SearchApiService (หรือ service ที่ใช้จริงในโปรเจกต์)
search = this.fb.control('');
results = toSignal(
this.search.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q ?? ''))
),
{ initialValue: [] }
);15. updateOn Strategy
ควบคุมได้ว่าจะ validate "เมื่อไหร่" — ทุกการพิมพ์ (change, default), ตอนออกจากช่อง (blur, รบกวนผู้ใช้น้อยกว่า), หรือตอนกด submit (submit):
typescript
// default: 'change' — ตรวจทุกครั้งที่พิมพ์
email = this.fb.control('', {
validators: [Validators.email],
updateOn: 'blur' // ตรวจเฉพาะตอนออกจากช่อง
});
// หรือกำหนดทั้ง group
form = this.fb.group({...}, { updateOn: 'submit' });text
'change' — ทุกครั้งที่พิมพ์ (default)
'blur' — ตอนออกจากช่อง
'submit' — ตอนกด submit16. Disable/Enable
ปิด/เปิดการแก้ไขฟอร์มทั้งก้อนหรือเฉพาะช่องได้ · ⚠️ จุดพลาด: ช่องที่ disable จะ ไม่ถูก validate และไม่อยู่ใน form.value — ถ้าต้องการค่ารวมช่อง disable ใช้ getRawValue():
typescript
this.form.disable();
this.form.enable();
this.form.controls.email.disable();
// แบบมีเงื่อนไข
if (someCondition) {
this.form.controls.address.disable();
}html
<!-- อัตโนมัติ: Angular ใส่ attribute disabled ให้ -->
<input formControlName="email">⚠️ ช่องที่ disabled จะไม่ถูก validate
typescript
// disabled = ไม่ถูกนำไป validate
// disabled = ไม่อยู่ใน form.value (ถ้าต้องการให้รวม ใช้ getRawValue())
this.form.value // ข้ามช่อง disabled
this.form.getRawValue() // รวมช่อง disabled17. Common Validator Library
ในงานจริงมักรวบ custom validator ที่ใช้ซ้ำไว้ใน class เดียว (เช่น strongPassword, notSameAs) — เรียกใช้ซ้ำได้ทุกฟอร์ม · หรือใช้ library สำเร็จรูปอย่าง ngx-validators (เป็น optional — ฟอร์มทั่วไปใช้ built-in เพียงพอ library เหล่านี้เหมาะกับฟอร์มซับซ้อนมาก ๆ ติดตั้งผ่าน npm):
bash
# ตัวอย่าง library ของนอก (3rd party) ที่ใช้บ่อย — ติดตั้งเพิ่มผ่าน npm ถ้าต้องการ
# npm install ngx-validators
# ngx-formly, ngx-validators, etc.typescript
// helper ที่ใช้บ่อย
export class CustomValidators {
static notSameAs(otherControl: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const other = control.parent?.get(otherControl);
if (!other) return null;
return control.value === other.value
? { sameAs: true }
: null;
};
}
static strongPassword(control: AbstractControl): ValidationErrors | null {
const value = control.value as string;
if (!value) return null;
const hasUpper = /[A-Z]/.test(value);
const hasLower = /[a-z]/.test(value);
const hasDigit = /\d/.test(value);
const hasSpecial = /[!@#$%^&*]/.test(value);
const longEnough = value.length >= 8;
return hasUpper && hasLower && hasDigit && hasSpecial && longEnough
? null
: { weakPassword: true };
}
}18. Multi-step Form Wizard
ฟอร์มยาว ๆ แบ่งเป็นหลายขั้น (wizard) ได้ — ใช้ FormGroup ซ้อนเป็นขั้น ๆ + signal เก็บ step ปัจจุบัน แล้ว validate ทีละขั้นก่อนให้ไปขั้นถัดไป:
typescript
@Component({...})
export class SignupWizardComponent {
private fb = inject(FormBuilder);
step = signal(0);
form = this.fb.nonNullable.group({
account: this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]]
}),
profile: this.fb.nonNullable.group({
name: ['', Validators.required],
age: [0, [Validators.required, Validators.min(18)]]
}),
preferences: this.fb.nonNullable.group({
newsletter: [false],
theme: ['light']
})
});
isStepValid(): boolean {
const groups = ['account', 'profile', 'preferences'] as const;
return this.form.controls[groups[this.step()]].valid;
}
next() {
if (this.isStepValid()) {
this.step.update(s => s + 1);
} else {
// as const = บอก TypeScript ว่า array นี้ไม่เปลี่ยนแปลง ทำให้ type แคบลงจาก string[] เป็น tuple ที่รู้ค่าแน่นอน
// — ผลคือ TypeScript รู้ว่า groups[i] จะเป็น 'account' | 'profile' | 'preferences' เท่านั้น ไม่ใช่ string ทั่วไป
const groups = ['account', 'profile', 'preferences'] as const;
const current = groups[this.step()];
this.form.controls[current].markAllAsTouched();
}
}
prev() {
this.step.update(s => Math.max(0, s - 1));
}
async submit() {
if (this.form.invalid) return;
// ในโปรเจกต์จริง: inject service ก่อน เช่น private api = inject(SignupApiService);
// ตัวอย่างนี้แสดง pattern การ submit เท่านั้น
console.log('Submit:', this.form.getRawValue());
}
}html
<form [formGroup]="form">
@switch (step()) {
@case (0) {
<div formGroupName="account">
<input formControlName="email" placeholder="Email">
<input formControlName="password" type="password">
</div>
}
@case (1) {
<div formGroupName="profile">
<input formControlName="name" placeholder="Name">
<input formControlName="age" type="number">
</div>
}
@case (2) {
<div formGroupName="preferences">
<label>
<input type="checkbox" formControlName="newsletter">
Subscribe
</label>
</div>
}
}
<div class="actions">
@if (step() > 0) {
<button type="button" (click)="prev()">Back</button>
}
@if (step() < 2) {
<button type="button" (click)="next()">Next</button>
} @else {
<button type="button" (click)="submit()">Submit</button>
}
</div>
</form>19. File Upload
อัปโหลดไฟล์ในฟอร์ม — เก็บ File ใน control, ตรวจขนาด/ชนิด, ทำ preview ด้วย FileReader แล้วส่งด้วย FormData (ส่วนนี้พอสำหรับบท Angular แล้ว — ถ้าอยากดู flow เต็ม ๆ ฝั่ง backend รับไฟล์ด้วย มีตัวอย่างเสริมนอกเล่มนี้ที่ fullstack/04 ไม่ใช่เนื้อหาบังคับของบทนี้):
typescript
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
// HttpClient ต้อง setup ใน app.config.ts ก่อน (provideHttpClient()) — ดูบทที่ 6
import { firstValueFrom } from 'rxjs';
// firstValueFrom = เอาค่าแรกจาก Observable มาเป็น Promise ใช้กับ async/await (ดูบท 6)
@Component({...})
export class FileUploadComponent {
private fb = inject(FormBuilder);
private http = inject(HttpClient); // inject HttpClient (ต้อง provideHttpClient() ใน app.config.ts)
// ใช้ fb.group() (ไม่ใช่ nonNullable.group) เพราะ avatar ต้องการ null เป็นค่าเริ่มต้น
form = this.fb.group({
avatar: this.fb.control<File | null>(null, Validators.required)
});
preview = signal<string | null>(null);
onFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert('Max 5 MB'); // ใหญ่เกิน 5 MB
return;
}
this.form.patchValue({ avatar: file });
// FileReader = อ่านไฟล์มาทำ preview (แสดงรูปก่อนอัปโหลด)
const reader = new FileReader();
reader.onload = () => this.preview.set(reader.result as string);
reader.readAsDataURL(file);
}
async upload() {
// ⚠️ form.value เป็น Partial<T> เสมอใน typed reactive forms — แต่ละช่องอาจเป็น undefined ได้
// แม้ตัว control จะ non-null ก็ตาม เพราะฉะนั้นต้องเช็ค !file ก่อนใช้งานทุกครั้ง (เหมือนที่ทำด้านล่าง)
const file = this.form.value.avatar;
if (!file) return;
const fd = new FormData();
fd.append('file', file);
await firstValueFrom(this.http.post('/api/upload', fd));
}
}html
<input type="file" accept="image/*" (change)="onFileChange($event)">
@if (preview(); as src) {
<img [src]="src" style="max-width: 200px">
}19.5 Angular Material Integration
🧩 ขั้นสูง/เสริม — ข้ามได้ถ้ายังไม่ได้ใช้: Angular Material เป็น UI library แยกต่างหาก ไม่ใช่ core ของ Reactive Forms (ที่เรียนมาข้อ 3-19 ใช้งานได้ครบโดยไม่ต้องพึ่งส่วนนี้เลย) ถ้าโปรเจกต์ยังไม่ได้ใช้ Material ข้ามไปข้อ 20 ได้เลย กลับมาอ่านตอนต้องใช้จริง
ในงานจริงระดับองค์กร (enterprise) ส่วนใหญ่มักใช้ Angular Material — ต้องติดตั้งก่อน:
bash
ng add @angular/materialReactive Form ผูกกับ mat-form-field:
typescript
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ReactiveFormsModule } from '@angular/forms';
@Component({
imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule],
template: `
<form [formGroup]="form">
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" required>
<!-- Angular Material แสดง error อัตโนมัติ — จะแสดงเมื่อ touched + invalid -->
<!-- ใช้ @if ตรวจชนิด error แต่ Material จัดการ show/hide ให้เองผ่าน ErrorStateMatcher -->
<mat-error>
@if (form.controls.email.hasError('required')) {
กรุณากรอก email
} @else if (form.controls.email.hasError('email')) {
email ไม่ถูกต้อง
} @else if (form.controls.email.hasError('emailExists')) {
email นี้มีคนใช้แล้ว
}
</mat-error>
<mat-hint>เราจะส่ง verification ไปที่นี่</mat-hint>
</mat-form-field>
</form>
`,
})💡
ErrorStateMatcher— ปรับเงื่อนไขว่าmat-errorแสดงเมื่อไหร่ (matcher= ฟังก์ชันตัดสิน "จะแสดง error ตอนไหน" ·AbstractControl= ชนิดทั่วไปของFormControl/FormGroup/FormArray— รับได้ทั้งหมด) Default (Angular Material 15+ MDC-based): แสดง error เมื่อtouched && invalid— ถ้าอยากให้แสดงทันที (เช่นหน้า signup) ใช้ custom matcher:typescriptclass ImmediateErrorMatcher implements ErrorStateMatcher { isErrorState(control: AbstractControl | null) { return !!(control?.invalid); // แสดงทันที ไม่รอ touch } }
20. Signal Forms (Angular 19/20+ Preview)
อนาคตของฟอร์มใน Angular — Signal Forms สร้างฟอร์มจาก signal ตรง ๆ (ไม่ต้อง FormBuilder) · ⚠️ ยัง experimental ณ ปี 2026 วิธีใช้ (syntax) ของ Signal Forms อาจเปลี่ยน ของจริงตอนนี้ยังใช้ Reactive Forms เป็นหลัก ดูไว้เผื่ออนาคต:
⚠️ ข้อควรรู้ก่อนอ่านหัวข้อนี้: โค้ดทุกตัวอย่างด้านล่างเป็น pseudo-code แนวคิด — ณ ปี 2026
@angular/forms/signalsยังไม่เป็น public API ที่ใช้งานได้จริง import path และ function เหล่านี้ยังไม่ confirmed โดย Angular team ถ้า copy-paste ไปรันจะได้Cannot find module '@angular/forms/signals'ทันที — อ่านเพื่อทำความเข้าใจ ทิศทาง เท่านั้น ติดตามสถานะล่าสุดที่ angular.dev
text
Status (ปี 2026):
- Angular 19 (2024): preview
- Angular 20+ (2026): ยังไม่ stable แน่นอน (timeline เคยเลื่อนมาแล้ว — ตรวจสถานะที่ angular.dev)
- API may change — Reactive Forms ยังเป็น defaultHello Signal Forms
⚠️ pseudo-code แนวคิด — import path และ API ยังไม่ stable อย่า copy-paste ไปรันจริง
text
// pseudo-code: import path ยังไม่ confirmed
import { form, validate, required, email, minLength } from '@angular/forms/signals';
@Component({...})
export class UserForm {
// กำหนด schema (โครงฟอร์ม) ตรง ๆ ไม่ต้องใช้ FormBuilder
user = form({
name: '',
email: '',
age: 0,
address: {
street: '',
city: ''
}
}, schema => {
validate(schema.name, [required(), minLength(2)]);
validate(schema.email, [required(), email()]);
validate(schema.address.city, [required()]);
});
submit() {
if (this.user.invalid()) return;
const value = this.user.value(); // มี type ครบ!
console.log(value);
}
}Template
⚠️ pseudo-code แนวคิด — directive
[field]ยังไม่มีใน Angular 19/20 จริง
text
<!-- pseudo-code: [field] directive ยังไม่มีใน Angular จริง -->
<form (ngSubmit)="submit()">
<input [field]="user.name">
@if (user.name.touched() && user.name.invalid()) {
<p class="error">{{ user.name.errors()[0]?.message }}</p>
}
<input [field]="user.email">
<!-- กลุ่มย่อย (nested) -->
<div>
<input [field]="user.address.street">
<input [field]="user.address.city">
</div>
<button type="submit" [disabled]="user.invalid()">Submit</button>
</form>→ แนวคิด: ผูก input เข้ากับ field ที่เป็น signal (สองทาง) โดยไม่ต้องใช้ formControlName
Key Differences vs Reactive Forms
text
Reactive Forms: Signal Forms (แนวคิด):
- FormGroup / FormControl - form({ ... })
- Observable (valueChanges) - Signal (value())
- get('name') / controls.name - เข้าตรง ๆ: user.name
- Validators.required (ฟังก์ชัน) - required() (ฟังก์ชันกำหนดกฎ)
- AsyncValidatorFn (validator แบบ async) - validateAsync()
- ต้อง subscribe (สมัครรับ) เอง - signal จัดการให้อัตโนมัติ
- เยิ่นเย้อ - กระชับDynamic Array
⚠️ pseudo-code แนวคิด
text
// pseudo-code
todos = form({
items: [{ text: '', done: false }]
}, schema => {
validate(schema.items.$each.text, [required()]);
});
addTodo() {
this.todos.items.append({ text: '', done: false });
}
removeTodo(index: number) {
this.todos.items.removeAt(index);
}text
<!-- pseudo-code: template แนวคิด -->
@for (item of todos.items.$each; track $index; let i = $index) {
<div>
<input [field]="item.text">
<input type="checkbox" [field]="item.done">
<button (click)="removeTodo(i)">×</button>
</div>
}
<button (click)="addTodo()">+ Add</button>Async Validator
⚠️ pseudo-code แนวคิด
text
// pseudo-code
import { validate, validateAsync } from '@angular/forms/signals';
user = form({ email: '' }, schema => {
validate(schema.email, [required(), email()]);
validateAsync(schema.email, async (value) => {
const exists = await this.userService.checkEmail(value);
return exists ? { emailExists: true } : null;
}, { debounce: 500 });
});Cross-Field Validator
⚠️ pseudo-code แนวคิด
text
// pseudo-code
import { validateTree } from '@angular/forms/signals';
user = form({ password: '', confirmPassword: '' }, schema => {
validate(schema.password, [required(), minLength(8)]);
validateTree(schema, (value) => {
if (value.password !== value.confirmPassword) {
return { mismatch: 'Passwords do not match' };
}
return null;
});
});ควรเลือกใช้อะไรตอนนี้ (When to Use)
text
✅ ใช้ Reactive Forms (เป็น default ในปี 2026):
- แอป production
- ทีมคุ้นกับ FormGroup
- โค้ดฟอร์มเยอะอยู่แล้ว
- ต้องพึ่ง ecosystem (ngx-formly ฯลฯ)
🧪 ติดตาม Signal Forms:
- ยังไม่มี public API ที่ stable
- รับได้กับ API ที่ยังทดลองอยู่
- อยากเห็นทิศทางอนาคต
- พร้อมเขียนใหม่ถ้า API เปลี่ยน
⚠️ การย้าย (migration):
- น่าจะมีเครื่องมือ ng generate ช่วยย้ายให้เมื่อ API stable
- ติดตามสถานะที่ angular.dev ก่อนตัดสินใจ migrateติดตาม angular.dev/guide/signals/forms สำหรับสถานะล่าสุด
21. ตัวอย่างเต็ม — Profile Form
รวมทุกอย่างในบทเป็นฟอร์มจริง — Profile ที่มี typed form + nested group + FormArray (การศึกษา/skill เพิ่มลบได้) + validation + แสดง error · ใช้เป็นแม่แบบฟอร์มซับซ้อนได้เลย:
typescript
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, FormArray, FormControl } from '@angular/forms';
interface Education {
school: string;
degree: string;
year: number;
}
interface ProfileForm {
name: FormControl<string>;
email: FormControl<string>;
bio: FormControl<string>;
educations: FormArray<FormGroup<{
school: FormControl<string>;
degree: FormControl<string>;
year: FormControl<number>;
}>>;
skills: FormArray<FormControl<string>>;
}
@Component({
selector: 'app-profile-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Profile</h1>
<!-- ข้อมูลพื้นฐาน -->
<div class="section">
<label>Name</label>
<input formControlName="name">
@if (isInvalid('name')) {
<p class="error">Name is required</p>
}
<label>Email</label>
<input formControlName="email" type="email">
@if (isInvalid('email')) {
<p class="error">{{ emailError() }}</p>
}
<label>Bio</label>
<textarea formControlName="bio" rows="3"></textarea>
</div>
<!-- ประวัติการศึกษา -->
<div class="section">
<h2>Education</h2>
<div formArrayName="educations">
@for (edu of educations.controls; track $index; let i = $index) {
<div [formGroupName]="i" class="edu-item">
<input formControlName="school" placeholder="School">
<input formControlName="degree" placeholder="Degree">
<input formControlName="year" type="number" placeholder="Year">
<button type="button" (click)="removeEducation(i)">×</button>
</div>
}
</div>
<button type="button" (click)="addEducation()">+ Add Education</button>
</div>
<!-- ทักษะ -->
<div class="section">
<h2>Skills</h2>
<div formArrayName="skills">
@for (skill of skills.controls; track $index; let i = $index) {
<div class="skill-item">
<input [formControlName]="i" placeholder="Skill">
<button type="button" (click)="removeSkill(i)">×</button>
</div>
}
</div>
<button type="button" (click)="addSkill()">+ Add Skill</button>
</div>
<!-- ปุ่มส่ง -->
<div class="actions">
<button type="submit" [disabled]="form.invalid || submitting()">
{{ submitting() ? 'Saving...' : 'Save' }}
</button>
</div>
@if (saved()) {
<p class="success">Saved!</p>
}
</form>
`,
styles: `
form { max-width: 600px; margin: 2rem auto; padding: 16px; }
.section { margin-bottom: 24px; padding: 16px; border: 1px solid #ddd; }
.edu-item, .skill-item { display: flex; gap: 8px; margin-bottom: 8px; }
.error { color: red; font-size: 12px; }
.success { color: green; }
`
})
export class ProfileFormComponent {
private fb = inject(FormBuilder);
submitting = signal(false);
saved = signal(false);
form: FormGroup<ProfileForm> = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
bio: ['', Validators.maxLength(500)],
// ✅ ใส่ generic type ครบ — ไม่ใช้ <any> ที่ทำให้เสียประโยชน์ typed forms
educations: this.fb.array<FormGroup<{
school: FormControl<string>;
degree: FormControl<string>;
year: FormControl<number>;
}>>([]),
skills: this.fb.array<FormControl<string>>([])
});
get educations() {
return this.form.controls.educations;
}
get skills() {
return this.form.controls.skills;
}
addEducation() {
this.educations.push(this.fb.nonNullable.group({
school: ['', Validators.required],
degree: [''],
year: [new Date().getFullYear()]
}));
}
removeEducation(i: number) {
this.educations.removeAt(i);
}
addSkill() {
this.skills.push(this.fb.nonNullable.control('', Validators.required));
}
removeSkill(i: number) {
this.skills.removeAt(i);
}
isInvalid(name: string): boolean {
const ctrl = this.form.get(name);
return !!(ctrl?.invalid && ctrl?.touched);
}
emailError(): string {
const errors = this.form.controls.email.errors;
if (errors?.['required']) return 'Email is required';
if (errors?.['email']) return 'Invalid email format';
return '';
}
async onSubmit() {
this.form.markAllAsTouched();
if (this.form.invalid) return;
this.submitting.set(true);
this.saved.set(false);
try {
const data = this.form.getRawValue();
console.log('Saving:', data);
await new Promise(r => setTimeout(r, 1000)); // จำลอง API ปลอม
this.saved.set(true);
} finally {
this.submitting.set(false);
}
}
}22. ⚠️ Common Pitfalls
ข้อผิดพลาดเรื่องฟอร์มที่เจอบ่อย — ตั้งแต่ลืม markAllAsTouched() ก่อน submit ไปจนถึง form.value ที่ไม่รวมช่อง disable · ตารางนี้รวมไว้ครบ:
| ❌ ที่มักทำผิด | ✅ ที่ถูก |
|---|---|
| ผสม Template + Reactive ในฟอร์มเดียว | เลือกอย่างใดอย่างหนึ่ง |
form.value ไม่รวมช่อง disabled | ใช้ form.getRawValue() |
| ตรวจทุกครั้งที่พิมพ์ (น่ารำคาญ) | ใช้ updateOn: 'blur' |
ลืม markAllAsTouched() ก่อน submit | mark touched เพื่อโชว์ error |
| reset แค่ค่า ไม่ reset สถานะ | reset() + markAsPristine() |
form.value ไม่มี type (string | null) | ใช้ nonNullable |
| FormArray ไม่มี track | ใช้ track $index |
| async validator หนักทุกครั้งที่พิมพ์ | ใช้ updateOn: 'blur' หรือ debounce ที่ระดับ valueChanges (ดู §6) |
| ลืม unsubscribe จาก valueChanges | ใช้ toSignal หรือ takeUntilDestroyed |
| submit แล้วหน้า reload | ใช้ (ngSubmit) (กัน reload ให้ในตัว) |
23. Form Best Practices
เช็กลิสต์แนวปฏิบัติที่ดีของฟอร์ม — ทำตามนี้แล้วฟอร์มจะ maintain ง่าย + UX ดี (เช่น โชว์ error เฉพาะตอน touched, disable ปุ่มเมื่อ invalid, reset หลังสำเร็จ):
text
✅ ใช้ Reactive Forms เป็นหลักในงานส่วนใหญ่
✅ Typed Forms (Angular 14+) — มี type ช่วยจับ bug
✅ ตรวจที่ระดับ FormGroup สำหรับเงื่อนไขข้ามช่อง
✅ จัดกลุ่มช่องที่เกี่ยวกันด้วย FormGroup
✅ ใช้ FormArray สำหรับรายการที่เพิ่ม/ลบได้
✅ โชว์ error เฉพาะตอนผู้ใช้แตะช่องแล้ว (touched)
✅ disable ปุ่ม submit เมื่อฟอร์มยัง invalid
✅ แสดงสถานะ loading ตอนกำลัง submit
✅ reset ฟอร์มหลังบันทึกสำเร็จ
✅ ใช้ ControlValueAccessor สำหรับ input ที่ทำเอง
✅ อย่าสร้างฟอร์มใหม่ทุกครั้งที่ค่าจากแม่เปลี่ยน24. Checkpoint
ฟอร์มต้องลงมือทำถึงจะคล่อง — ทำ 5 ข้อนี้ไล่จากฟอร์ม login ง่าย ๆ ไปจนถึง dynamic form + custom control:
🛠️ Checkpoint 5.1 — Simple Form
Login form:
- email (required, email)
- password (required, minLength 8)
- Show errors when touched
- Disable submit when invalid
🛠️ Checkpoint 5.2 — Cross-field
Signup form:
- password + confirmPassword
- Custom validator at group level
- Display "passwords don't match"
🛠️ Checkpoint 5.3 — FormArray
Recipe form:
- title, description
- ingredients (FormArray of { name, qty, unit })
- steps (FormArray of string)
- Add/Remove items
- Submit valid recipe
🛠️ Checkpoint 5.4 — Async Validator
Email uniqueness check:
- async validator that calls API
- debounce 500ms
- Show "checking..." indicator
🛠️ Checkpoint 5.5 — Multi-Step Wizard
3-step signup wizard:
- Each step validated separately
- Next/Back navigation
- Final submit
25. สรุปบท
ทบทวนแกนของบท — เน้น Reactive Forms (สร้างใน TS, type-safe, test ง่าย) ประกอบจาก FormControl/FormGroup/FormArray + validator · จับ 3 บล็อกนี้ + การแสดง error ตอน touched ได้ ก็ทำฟอร์มจริงได้แล้ว:
✅ Reactive Forms (แนะนำกว่า Template-driven) ✅ FormBuilder → fb.group(...), fb.array(...), fb.control(...) (ช่วยเขียนสั้น) ✅ Validators สำเร็จรูป: required, email, min, max, minLength, pattern ✅ Custom validator (เขียนเอง): ValidatorFn (sync), AsyncValidatorFn (async) ✅ Cross-field (ข้ามหลายช่อง): ใส่ validator ที่ระดับ FormGroup ✅ Typed Forms (Angular 14+) — ใช้ fb.nonNullable ให้ค่าไม่เป็น null ✅ FormArray สำหรับรายการ dynamic (เพิ่ม/ลบได้) — ใส่ track $index ✅ valueChanges → toSignal แปลงเป็น signal ใช้ใน UI ✅ updateOn strategy: 'change' (ทุกครั้งที่พิมพ์) / 'blur' (ออกจากช่อง) / 'submit' (กดส่ง) ✅ ControlValueAccessor สำหรับ component ฟอร์มทำเอง ✅ markAllAsTouched() ก่อน submit เพื่อโชว์ error ทุกช่อง ✅ form.getRawValue() ดึงค่ารวมช่องที่ disable