Skip to content

บทที่ 5 — Forms (Template + Reactive)

← บทที่ 4 | สารบัญ | บทที่ 6 →

หลังจบบท คุณจะ:

  • เลือกระหว่าง 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 templateTypeScript 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 ของ directive ngForm (ไม่ใช่แค่ 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 ก่อนถึงจะเข้าใจเต็ม ๆ):

  1. of(control.value) emit (ปล่อยค่าออก) ครั้งเดียวแล้วจบทันที
  2. เพราะงั้น debounce (หน่วงรอนิ่ง) จึงทำอะไรไม่ได้ — ไม่มีสตรีมให้หน่วง
  3. 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)
);
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'  — ตอนกด submit

16. 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()      // รวมช่อง disabled

17. 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/material

Reactive 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:

typescript
class 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 ยังเป็น default

Hello 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() ก่อน submitmark 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) ✅ FormBuilderfb.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 $indexvalueChangestoSignal แปลงเป็น signal ใช้ใน UI ✅ updateOn strategy: 'change' (ทุกครั้งที่พิมพ์) / 'blur' (ออกจากช่อง) / 'submit' (กดส่ง) ✅ ControlValueAccessor สำหรับ component ฟอร์มทำเอง ✅ markAllAsTouched() ก่อน submit เพื่อโชว์ error ทุกช่อง ✅ form.getRawValue() ดึงค่ารวมช่องที่ disable


← บทที่ 4 | บทที่ 6 → HTTP + RxJS