Skip to content

บทที่ 2 — Signals และ State Management

← บทที่ 1 | สารบัญ | บทที่ 3 →

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

  • เข้าใจ Signal — ระบบ reactivity ตัวใหม่ของ Angular
  • ใช้ signal, computed, effect ได้คล่อง
  • เลือกใช้ Signal vs RxJS (library จัดการข้อมูล async — จะสอนละเอียดบท 6)
  • ทำ shared state ระหว่าง component

ก่อนอ่าน: ควรผ่าน บทที่ 1 มาก่อน (รู้จัก component + เคยเห็น signal() ผ่าน ๆ) · บทนี้คือหัวใจของ Angular ยุคใหม่ — reactivity (รีแอคทิวิตี้ — ระบบที่ทำให้ UI อัปเดตเองเมื่อข้อมูลเปลี่ยน ไม่ต้องสั่งวาดเอง) ทั้งหมดต่อยอดจากที่นี่


1. ทำไม Angular เพิ่ม Signal

ก่อนจะเรียนว่า signal ใช้ยังไง ต้องเข้าใจก่อนว่า มันมาแก้ปัญหาอะไร — ไม่งั้นจะรู้สึกว่าเรียนของใหม่โดยไม่รู้เหตุผล

ปัญหาเดิม (ก่อน Angular 17): Angular ใช้ library ชื่อ Zone.js (Zone.js = library ที่ดักจับ async API เพื่อสั่งตรวจการเปลี่ยนแปลงอัตโนมัติ) คอย "ดักจับทุกอย่างที่อาจทำให้ข้อมูลเปลี่ยน" (คลิก, timer, การตอบกลับจาก API) แล้วสั่งตรวจทั้งหน้าจอว่ามีอะไรต้องวาดใหม่ไหม

วิธีนี้ได้ผล แต่ ตรวจเยอะเกินจำเป็น — เปลี่ยนค่าเดียวแต่ไล่เช็คทั้งหน้า

การจะ optimize (ปรับให้เร็วขึ้น) ก็ต้องใช้ RxJS (library async — เรียนเต็มในบท 6) หรือ OnPush (โหมดประหยัด Change Detection — เรียนเต็มในบท 7) ซึ่งมือใหม่มักงงกับสองอย่างนี้

ทางแก้ใหม่ (Angular 17+) — Signal: แทนที่จะ "เดา" ว่าอะไรเปลี่ยน เราบอก Angular ตรง ๆ ว่า "ค่านี้เปลี่ยนนะ" ผ่าน signal → Angular อัปเดตเฉพาะจุดที่ใช้ค่านั้นจริง ๆ (fine-grained = ละเอียดถึงระดับชิ้นเล็ก ไม่เหวี่ยงทั้งหน้า) ไม่ต้องไล่ทั้งหน้า ผลคือเร็วขึ้น เข้าใจง่ายขึ้น และเปิดทางไปสู่ Zoneless (โหมดที่ Angular ไม่ต้องพึ่ง Zone.js อีกต่อไป — Angular 18 experimental → Angular 20 stable)

🔍 เปรียบเทียบ: signal เหมือน ช่องในสเปรดชีต (Excel) — ช่อง C1 ตั้งสูตร =A1+B1 ไว้ พอแก้ A1 ช่อง C1 คำนวณใหม่เองทันที โดยที่ช่องอื่นที่ไม่เกี่ยวไม่ต้องคำนวณใหม่ · Angular signal ทำงานแบบเดียวกันเป๊ะ — แก้ค่าต้นทาง สิ่งที่อ้างอิงอยู่ update เอง เฉพาะที่เกี่ยว
(ข้อมูลเสริมเชิงประวัติ — ข้ามได้: แนวคิดนี้ได้แรงบันดาลใจจาก SolidJS ซึ่งเป็น framework frontend อีกตัวหนึ่ง ไม่ต้องรู้จักก็อ่านบทนี้ได้)


2. Signal คืออะไร

typescript
import { signal } from '@angular/core';

const count = signal(0);                   // signal ที่เขียนค่าได้ (writable)

count();                                    // 0 (อ่านค่า)
count.set(5);                               // เขียนค่าใหม่
count.update(c => c + 1);                  // อัปเดตจากค่าเดิม

console.log(count());                       // 6

Signal คือ "กล่องเก็บค่า" ที่ส่งสัญญาณเตือนเมื่อค่าข้างในเปลี่ยน — ใครที่อ้างอิงค่านี้อยู่จะรู้ตัวและอัปเดตตาม

💡 ทำไมอ่านค่าต้องใส่วงเล็บ count()? เพราะ signal เป็น "ฟังก์ชัน" จริง ๆ — ตอนเรียก count() มันไม่ได้แค่คืนค่า แต่ยัง "จดทะเบียน" ว่าตรงนี้กำลังใช้ค่านี้อยู่ ทำให้ Angular รู้ว่าต้องอัปเดตตรงนี้เมื่อค่าเปลี่ยน · ส่วนการเขียนค่าใช้ .set() (ใส่ค่าใหม่ตรง ๆ) หรือ .update() (คำนวณจากค่าเดิม)

Read vs Write

typescript
const name = signal('Anna');

// อ่าน — เรียกเหมือนฟังก์ชัน
console.log(name());           // 'Anna'

// เขียน — ใช้ set หรือ update
name.set('Ben');
name.update(n => n.toUpperCase());

3. Computed — Derived Signal

typescript
import { signal, computed } from '@angular/core';

const firstName = signal('Anna');
const lastName = signal('Smith');

const fullName = computed(() => `${firstName()} ${lastName()}`);

console.log(fullName());       // 'Anna Smith'

firstName.set('Ben');
console.log(fullName());       // 'Ben Smith' — คำนวณใหม่ให้อัตโนมัติ
typescript
// ตัวอย่างเพิ่มเติม
// interface Item { id: number; name: string; price: number; }  ← type ที่ใช้ในตัวอย่างนี้
const items = signal<Item[]>([]);
const total = computed(() => items().reduce((s, i) => s + i.price, 0));  // reduce = วนทุก item แล้วรวมค่า price ทีละตัว
const itemCount = computed(() => items().length);
const expensive = computed(() => items().filter(i => i.price > 100));
const isEmpty = computed(() => items().length === 0);

Computed = signal ที่คำนวณมาจาก signal อื่น (derived) — เหมือนช่องสูตรในสเปรดชีต มันเป็นแบบ lazy (คำนวณเมื่อมีคนอ่านเท่านั้น) และ memoized (จำผลไว้ ไม่คำนวณซ้ำถ้า dependency ไม่เปลี่ยน) → คำนวณใหม่อัตโนมัติเมื่อ signal ที่มันอ้างอิงเปลี่ยน

💡 dependency ในที่นี้หมายถึง "signal ที่ computed อ้างอิงอยู่" — เช่น computed(() => firstName() + lastName()) มี dependency คือ firstName และ lastName

Lazy Evaluation

typescript
const expensive = computed(() => {
    console.log('computing...');
    return slowFn();   // slowFn() = สมมติว่าเป็นฟังก์ชันที่คำนวณนาน (เช่น filter ข้อมูลเยอะ ๆ)
});

// ยังไม่ถูกเรียก — ไม่มี log
expensive();   // log 'computing...' — อ่านครั้งแรก
expensive();   // ไม่ log — จำผลไว้แล้ว (memoized)
expensive();   // ไม่ log — ยังจำผลเดิมอยู่

→ computed จะคำนวณใหม่เฉพาะตอนที่มีคนมาอ่านค่า (pull) ไม่ใช่ทันทีทุกครั้งที่ dependency เปลี่ยน (push) — ถ้าไม่มีใครอ่าน ก็ยังไม่คำนวณ


4. Effect — Side Effects

typescript
import { Component, signal, effect } from '@angular/core';

@Component({ selector: 'app-demo', standalone: true, template: '' })  // standalone: true = component แบบใหม่ที่ไม่ต้องอยู่ใน NgModule (บท 1)
export class DemoComponent {
    count = signal(0);

    constructor() {
        effect(() => {
            console.log('count =', this.count());
        });
    }

    demo() {
        // ตอนเริ่ม: log "count = 0"
        this.count.set(5);    // log "count = 5"
        this.count.set(10);   // log "count = 10"
    }
}

Effect = "ทำงานข้างเคียง" (side effect) เมื่อ signal เปลี่ยน เช่น เขียน log, บันทึก localStorage, เปลี่ยน title หน้าเว็บ · มันรัน 1 ครั้งตอนเริ่ม แล้วรันซ้ำทุกครั้งที่ signal ที่อ่านอยู่ใน body เปลี่ยน (ต่างจาก computed ตรงที่ effect ไม่คืนค่า แต่ไป "ทำอะไรบางอย่าง" กับโลกภายนอก)

Use Cases (กรณีที่ใช้)

typescript
// หมายเหตุ: effect() ต้องเรียกใน injection context — โดยปกติคือ constructor หรือ field initializer
// ตัวอย่างด้านล่างสมมติว่าอยู่ใน constructor() ของ component

// ซิงค์ค่าลง localStorage
constructor() {
    effect(() => {
        localStorage.setItem('count', String(this.count()));
    });

    // บันทึกฟอร์มอัตโนมัติ
    // (สมมติว่า this.form คือ signal ที่เก็บข้อมูลฟอร์ม — dirty() = มีการแก้ไขแล้ว, value() = ค่าปัจจุบัน)
    effect(() => {
        if (this.form.dirty()) {
            autoSave(this.form.value());
        }
    });

    // เขียน log
    effect(() => {
        console.log('user changed:', this.user());
    });

    // เปลี่ยน title ของหน้าเว็บ
    effect(() => {
        document.title = `${this.pageTitle()} | My App`;
    });
}

Cleanup (การเก็บกวาด)

typescript
effect((onCleanup) => {
    const timer = setInterval(() => {
        console.log(this.count());
    }, 1000);
    
    onCleanup(() => clearInterval(timer));    // เก็บกวาดก่อนรันรอบถัดไป
});

→ คล้ายการเก็บกวาด (cleanup = ล้างของเก่าก่อนรอบใหม่) ใน lifecycle hook — เคลียร์ resource เก่าก่อนรันรอบใหม่ (สำหรับคนที่เคยใช้ React: เหมือน return ของ useEffect)

Effect Constraints (ข้อจำกัดของ Effect)

🚧 ขั้นสูง — ข้ามได้รอบแรก ทั้งหัวข้อ "Effect Constraints" (รวม 3 callout ลึก ๆ ด้านล่าง: deprecate / Glitch-free / Injection Context) เป็น concept ที่จะใช้ตอนทำงานจริงเท่านั้น · มือใหม่รู้แค่ "effect = ทำ side effect เวลา signal เปลี่ยน" ก็พอ — ข้ามไปข้อ 5 (Untracked) ก่อนได้ แล้วค่อยกลับมาอ่านหลังเริ่มเขียนโปรเจกต์จริง · หมายเหตุ: โค้ดตัวอย่างด้านล่างบางส่วนใช้ untracked() — อธิบายละเอียดในข้อ 5

🎯 Update Angular 19+: { allowSignalWrites: true } ถูก deprecate (ดี-เพร-เคต — ประกาศเลิกใช้ ยังใช้ได้แต่จะหายในเวอร์ชันถัดไป) แล้ว — Angular 19+ อนุญาตให้เขียน signal จาก effect ได้โดยไม่ต้องใส่ option นี้ แต่ ยังต้องระวัง infinite loop เหมือนเดิม — การ deprecate option ไม่ได้ทำให้ pattern ปลอดภัยขึ้นโดยอัตโนมัติ

pattern ที่ เสี่ยง infinite loop (วงวนไม่รู้จบ — effect เขียนค่า → ค่าเปลี่ยน → effect รันใหม่ → เขียนอีก... ทำให้แอปแฮง) คือ "อ่าน signal A → เขียน A" โดยไม่มีเงื่อนไขหยุด — ทางแก้:

  • ใช้ untracked() (อ่านค่าโดยไม่จดทะเบียนติดตาม) ครอบการเขียน หรือ
  • เปลี่ยนไปใช้ computed() / linkedSignal() แทน
typescript
// ✅ อ่าน signal ได้
effect(() => {
    console.log(this.count());
});

// ⚠️ เขียน signal ได้ (Angular 19+) แต่ระวัง: นี่คือตัวอย่างที่มีปัญหา
effect(() => {
    const c = this.count();
    if (c > 100) {
        this.count.set(100);    // ⚠️ ตัวอย่างนี้ (มีทางเขียนค่าเดียว) รันซ้ำ 2 รอบแล้วหยุด — แต่ไม่ใช่การรับประกันทั่วไป ถ้ามีที่อื่น set ค่า >100 ซ้ำ ๆ จะกลายเป็น infinite loop ได้ ทางที่ปลอดภัยกว่าคือเปลี่ยนไปใช้ untracked()/computed()/linkedSignal() แทนเสมอ ไม่ใช่หวังว่ามันจะ "นิ่ง" เอง
    }
});

// ✅ ทางออกที่ปลอดภัย — untracked() ครอบการอ่านที่ไม่ต้อง track
effect(() => {
    const trigger = this.input();         // tracked — re-run เมื่อ input เปลี่ยน
    untracked(() => {
        const current = this.count();      // อ่านโดยไม่ track
        this.count.set(current + trigger); // เขียนปลอดภัย ไม่ loop
    });
});

📖 Glitch-free guarantee (รับประกันว่าจะไม่มีค่าคลาดเคลื่อนชั่วคราว) (จุดขายของ signal): Angular signal ใช้ push-pull algorithm (อัลกอริทึมแบบ "ดัน-ดึง" — ขั้นแรกแค่ทำเครื่องหมายว่าต้องคำนวณใหม่ (push) ขั้นสองค่อยคำนวณจริงตอนถูกอ่าน (pull)) — เวลา signal A เปลี่ยน → mark dependents (ทำเครื่องหมายตัวที่อ้างอิงอยู่) ทั้ง chain เป็น "dirty" (สถานะ "ค่าเก่าใช้ไม่ได้แล้ว ต้องคำนวณใหม่") → ตอนอ่านค่า computed ค่อยคำนวณจริง

ผลคือ: computed ที่ depend หลาย signals จะไม่เห็น intermediate state (สถานะระหว่างกลาง) ที่ inconsistent (ไม่สอดคล้องกัน — เช่น a อัปเดตแล้ว แต่ b ยังเป็นค่าเก่า) ต่างจาก naive observer pattern (เช่น RxJS combineLatest) ที่อาจเห็น "ค่ากลางที่ผิด" ระหว่างที่ signal ยังอัปเดตไม่ครบ

typescript
const a = signal(1);
const b = computed(() => a() * 2);
const c = computed(() => a() + b());   // ไม่มีจังหวะที่ c เห็น a=ใหม่ แต่ b=เก่า

🔴 effect() Injection Context — กับดักคลาสสิค:

  • injection context = ช่วงเวลาที่ Angular อนุญาตให้เรียก inject() / effect() ได้ (ตอน constructor() หรือ field initializer = การกำหนดค่าเริ่มต้นให้ property ของ class ตรง ๆ เช่น count = signal(0) ที่อยู่นอก constructor)
  • effect ใน constructor() หรือ field initializer = ✅ ทำงานได้
  • effect ใน ngOnInit(), method ปกติ, callback = ❌ throw NG0203 (รหัส error ของ Angular บอกว่าเรียก inject()/effect() ผิดที่ — ถ้าเจอใน console ค้นที่ angular.dev/errors/NG0203): "inject() must be called from an injection context"
  • ทางแก้: ส่ง { injector } option หรือใช้ runInInjectionContext()
typescript
private injector = inject(Injector);

startWatching() {
    // ❌ ไม่อยู่ใน injection context
    effect(() => console.log(this.count()));

    // ✅ pass injector ชัด ๆ
    effect(() => console.log(this.count()), { injector: this.injector });

    // ✅ หรือใช้ runInInjectionContext
    runInInjectionContext(this.injector, () => {
        effect(() => console.log(this.count()));
    });
}

5. Untracked — อ่านโดยไม่สมัครรับการเปลี่ยนแปลง (Read Without Subscribing)

ปกติเวลา effect/computed อ่าน signal มันจะ "สมัครรับ" การเปลี่ยนของ signal นั้นอัตโนมัติ · แต่บางครั้งเราอยากแค่ "แอบดูค่าปัจจุบัน" โดยไม่ให้มันทริกเกอร์ตอนค่านั้นเปลี่ยน — ใช้ untracked() ครอบ:

typescript
import { untracked } from '@angular/core';

const userId = signal(1);
const timestamp = signal(Date.now());

effect(() => {
    const id = userId();                              // ติดตาม (tracked) — เปลี่ยนแล้ว effect รันใหม่
    const ts = untracked(() => timestamp());           // ไม่ติดตาม (not tracked) — แค่แอบดูค่า
    
    log(id, ts);
});

// userId เปลี่ยน → effect รัน ✅
// timestamp เปลี่ยน → effect ไม่รัน (เพราะ untracked)

→ อ่านค่าได้ แต่ไม่ subscribe (ไม่ผูกให้ทริกเกอร์เมื่อค่านั้นเปลี่ยน)


6. Signal in Component

มาดูการใช้ signal จริงใน component — ประกาศ signal เป็น property ของ class แล้วเรียกใน template ด้วยวงเล็บ count() · นี่คือรูปแบบที่จะใช้ตลอดทั้งเล่ม:

typescript
import { Component, signal, computed } from '@angular/core';

@Component({
    selector: 'app-counter',
    standalone: true,
    template: `
        <p>Count: {{ count() }}</p>
        <p>Doubled: {{ doubled() }}</p>
        <button (click)="increment()">+</button>
        <button (click)="decrement()">-</button>
        <button (click)="reset()">Reset</button>
    `
})
export class CounterComponent {
    count = signal(0);
    doubled = computed(() => this.count() * 2);
    
    increment() {
        this.count.update(c => c + 1);
    }
    
    decrement() {
        this.count.update(c => c - 1);
    }
    
    reset() {
        this.count.set(0);
    }
}

→ ใน template เรียก signal เหมือนฟังก์ชัน — count()


7. Signal vs Property — เปรียบเทียบ

ทำไมต้องใช้ signal แทน property ธรรมดา? ดูโค้ดเทียบกัน — แบบเก่าต้องคอยสั่ง markForCheck() เองเมื่อใช้ OnPush ส่วน signal อัปเดตหน้าจอให้อัตโนมัติ เขียนน้อยกว่าและพลาดยากกว่า

💡 ChangeDetectorRef / OnPush เป็นเรื่องของ บท 7 — ตอนนี้แค่รู้ว่า: ChangeDetectorRef คือ object ที่ Angular ใช้ควบคุมการวาดหน้าจอใหม่ ถ้าใช้ property ธรรมดา + โหมด OnPush ต้อง "สั่งอัปเดต" หน้าจอเองทุกครั้ง (cdr.markForCheck()) · ส่วน signal ทำให้ Angular รู้เองโดยไม่ต้องสั่ง — เขียนน้อยกว่าและพลาดยากกว่า

แบบเก่า (property + สั่งอัปเดตเอง)

typescript
export class Old {
    count = 0;
    
    constructor(private cdr: ChangeDetectorRef) {}
    
    increment() {
        this.count++;
        this.cdr.markForCheck();    // ต้องสั่งเองถ้าใช้ OnPush (OnPush = โหมดตรวจเฉพาะเมื่อจำเป็น, บท 7)
    }
}

แบบใหม่ (signal)

typescript
export class New {
    count = signal(0);
    
    increment() {
        this.count.update(c => c + 1);
    }
}

→ โค้ดน้อยกว่า + วาดใหม่เฉพาะจุด (fine-grained = ละเอียดถึงระดับชิ้นเล็ก ไม่เหวี่ยงทั้งหน้า) ให้อัตโนมัติ


8. Signal Input/Output (Angular 17.1+)

ตั้งแต่ Angular 17.1 การรับ-ส่งข้อมูลระหว่าง component ก็เป็น signal ด้วย — Input = ข้อมูลที่ parent component ส่งเข้ามาให้ (เช่น title, description), Output = event ที่ child ส่งออกไปให้ parent รับ — ถ้ายังไม่คุ้นดูบท 1 ก่อน · input() (รับเข้า), output() (ส่งออก), model() (สองทาง) ทำให้ทั้งแอปใช้โมเดล reactivity แบบเดียวกันหมด:

typescript
import { Component, input, output, model } from '@angular/core';

@Component({
    selector: 'app-card',
    template: `
        <h3>{{ title() }}</h3>
        <p>{{ description() }}</p>
        <button (click)="closed.emit()">Close</button>
        <!-- ($event.target as any).value = ดึงค่าจาก HTML input element — ต้อง cast เพราะ TypeScript ไม่รู้ชนิด target โดยอัตโนมัติ -->
        <input [value]="name()" (input)="name.set(($event.target as any).value)">
    `
})
// ⚠️ 'as any' ข้าม type-check ไปเลย ใช้สะดวกแต่เสี่ยง — TypeScript ที่ควรจับบั๊กให้จะไม่ทำงาน
// ในโค้ดจริงแนะนำให้ cast ชัดเจนกว่านี้ เช่น (event.target as HTMLInputElement).value
export class CardComponent {
    title = input.required<string>();              // input บังคับต้องส่งมา
    description = input('');                        // input ไม่บังคับ มีค่า default
    
    closed = output<void>();                        // output ไม่มีข้อมูลแนบ
    deleted = output<string>();                     // output มีข้อมูลแนบ
    
    name = model('');                                // ผูกสองทาง (two-way) แบบ signal
}

→ Input/Output เป็น signal — ใช้โมเดล reactivity แบบเดียวกันทั้งหมด


9. Shared State via Service (Signal ใน Service กลาง)

วิธีแชร์ข้อมูลข้าม component ที่ไม่เกี่ยวกันโดยตรง: เก็บ signal ไว้ใน service กลาง — component ไหนแก้ก็ได้ component ที่ใช้ค่านั้นอัปเดตตามเอง (เช่น ตะกร้าสินค้ากับไอคอนตะกร้าบน navbar):

typescript
// interface CartItem { id: number; name: string; price: number; qty: number; }  ← ต้องประกาศก่อนใช้

// service กลางที่ใช้ร่วมกัน
@Injectable({ providedIn: 'root' })
export class CartService {
    items = signal<CartItem[]>([]);
    
    total = computed(() => 
        this.items().reduce((s, i) => s + i.price * i.qty, 0)
    );
    
    add(item: CartItem) {
        this.items.update(items => [...items, item]);
    }
}
typescript
// component A
// interface Product { id: number; name: string; price: number; }  ← สมมติว่ามีอยู่แล้ว เช่น import จากไฟล์ models.ts
export class ProductCardComponent {     // ตัวเพิ่มของลงตะกร้า
    private cart = inject(CartService);
    
    addToCart(product: Product) {
        this.cart.add({ ...product, qty: 1 });
    }
}
typescript
// component B
@Component({
    template: `<span class="badge">{{ cart.items().length }}</span>`
})
export class CartIconComponent {       // ไอคอนตะกร้าบน navbar
    cart = inject(CartService);
}

→ ซิงค์อัตโนมัติ — A เพิ่มของ → B อัปเดตเอง


10. ngOnChanges Replacement — Effect

เมื่อก่อนถ้าอยากทำอะไรตอน input เปลี่ยน ต้องใช้ ngOnChanges + API SimpleChanges ที่ยุ่ง · ตอนนี้ถ้า input เป็น signal ใช้ effect() แทนได้เลย สะอาดกว่ามาก

💡 ngOnChanges / SimpleChanges = lifecycle hook + type ของ Angular แบบเก่า (ดูบท 1 §10 — หรือถ้าจำไม่ได้ก็ข้ามไปดูแบบใหม่ด้านล่างได้เลย)

typescript
// แบบเก่า
export class Old implements OnChanges {
    @Input() userId!: number;
    
    ngOnChanges(changes: SimpleChanges) {
        if (changes['userId']) {
            this.loadUser(changes['userId'].currentValue);
        }
    }
}

// แบบใหม่ — ใช้ effect บน input signal
export class New {
    userId = input.required<number>();
    
    constructor() {
        effect(() => {
            this.loadUser(this.userId());
        });
    }
}

→ สะอาดกว่า ไม่ต้องยุ่งกับ SimpleChanges API


10.5. linkedSignal (Angular 19+)

🚧 ขั้นสูง — ข้ามได้รอบแรก หัวข้อ 10.5 (linkedSignal) + 10.6 (custom equality) เป็นของ Angular 19+ ที่จะใช้ตอน state เริ่มซับซ้อน · มือใหม่อ่านพอให้รู้ว่ามี — ข้ามไปข้อ 11 ก่อนได้ แล้วค่อยกลับมาตอนต้องใช้จริง

computed คำนวณจาก signal อื่นแต่ แก้ค่าเองไม่ได้ · บางครั้งเราอยากได้ค่าที่ "คำนวณมา default แต่ผู้ใช้ override ได้" เช่น เลือก item แรกให้อัตโนมัติแต่กดเลือกอันอื่นได้ — นั่นคือ linkedSignal:

ตัวอย่างนี้ใช้ type Item — สมมติว่า interface Item { id: number; name: string; price: number; } ประกาศไว้แล้ว

typescript
import { linkedSignal, signal } from '@angular/core';

@Component({...})
export class FormComponent {
    items = signal<Item[]>([]);
    
    // computed = อ่านอย่างเดียว, คำนวณมาจากอื่น
    // linkedSignal = คำนวณมาเหมือนกัน แต่เขียนทับได้ (override)
    selected = linkedSignal(() => this.items()[0] ?? null);
    
    selectFirst() {
        // เมื่อ items() เปลี่ยน → selected คำนวณใหม่
    }
    
    selectManual(item: Item) {
        this.selected.set(item);    // ✅ เขียนได้ — ทับค่าที่คำนวณมา
    }
}

→ Use case: derived state ที่ user override ได้ (เช่น default selection)

linkedSignal with Source

typescript
const tabs = signal<string[]>(['Profile', 'Settings']);

const active = linkedSignal({
    source: tabs,
    computation: (newTabs, previous) => {
        // ถ้าค่าเดิมยังอยู่ในรายการ tab ก็เก็บไว้ ไม่งั้น reset ไปตัวแรก
        if (previous && newTabs.includes(previous.value)) {
            return previous.value;
        }
        return newTabs[0] ?? null;
    }
});

active.set('Settings');  // ผู้ใช้เลือก Settings ด้วยตนเอง
tabs.set(['Profile', 'Security', 'Settings']);
// active() = 'Settings' (คงค่าเดิมไว้เพราะ 'Settings' ยังอยู่ในรายการ)

tabs.set(['Help']);
// active() = 'Help' (เดิมเป็น 'Settings' แต่ไม่อยู่ในรายการแล้ว เลย reset)

→ reset แบบ "ฉลาด" — คงค่าที่ผู้ใช้เลือกไว้เท่าที่ทำได้


10.6. Custom Equality Function

signal ตัดสินว่า "ค่าเปลี่ยนไหม" ด้วยการเทียบแบบ === (reference = เทียบ "ที่อยู่" ของ object ในหน่วยความจำ ไม่ใช่ค่าข้างใน) — ปัญหาคือ object ใหม่ที่หน้าตาเหมือนเดิมจะถูกมองว่า "เปลี่ยน" ทำให้ re-render เกินจำเป็น · แก้ได้ด้วยการกำหนดวิธีเทียบเอง (equal):

typescript
// default — เทียบด้วย reference (===)
const user = signal({ id: 1, name: 'Anna' });

user.set({ id: 1, name: 'Anna' });    // ⚠️ object ตัวใหม่ — signal แจ้งว่าเปลี่ยน
// → effect/computed รันใหม่ทั้งที่ "ค่าเหมือนเดิม"

Custom equality

typescript
import { signal } from '@angular/core';

const user = signal(
    { id: 1, name: 'Anna' },
    {
        equal: (a, b) => a.id === b.id && a.name === b.name
    }
);

user.set({ id: 1, name: 'Anna' });    // ✅ ค่าเดียวกัน — ไม่แจ้งเตือน

Deep Equality

📦 lodash-es ไม่ใช่ของ Angular — เป็น library แยกต่างหาก (utility library ที่ช่วยเรื่อง deep comparison) ต้องติดตั้งเพิ่มเองก่อนใช้ (เหมือนวิธี npm install ที่เคยเห็นตอน setup โปรเจกต์ในบทที่ 0):

bash
npm install lodash-es
npm install @types/lodash-es --save-dev
typescript
import { isEqual } from 'lodash-es';

const items = signal<Item[]>([], {
    equal: isEqual    // เทียบลึก (เทียบค่าข้างในทุกชั้น)
});

→ เลี่ยงการ re-render ที่ไม่จำเป็น ด้วยการเทียบโครงสร้าง (structural equality)


11. RxJS Subject Family (Mini Recap — ทบทวนสั้น ๆ)

🚀 โซนขั้นสูง — ข้ามได้ กลับมาอ่านหลังบท 6

หัวข้อนี้พูดถึง RxJS (RxJS = library จัดการข้อมูลที่ไหลมาเป็นสาย) และ Observable (สายข้อมูล async ที่ส่งได้หลายค่าตามเวลา) ซึ่งเล่มนี้สอนเต็ม ๆ ใน บท 6 · ถ้ายังไม่เคยเห็น RxJS มาก่อน อ่านตรงนี้แล้วจะงงเป็นธรรมดา — ข้ามไปข้อ 11.5 ก่อนได้ แล้วค่อยกลับมาอ่านหลังจบบท 6 · มือใหม่ใช้ signal (ข้อ 1–10) ทำงานส่วนใหญ่ได้อยู่แล้ว Subject family เป็นของเสริมไว้อ่านโค้ดเก่า/งาน async ขั้นสูง

ก่อนมี signal, Angular ใช้ RxJS จัดการ state — ยังเจอในโค้ดเก่าและงาน async อยู่ จึงควรรู้จัก Subject 4 ตระกูลนี้ว่าต่างกันยังไง (สรุปสั้น ๆ ไว้เทียบกับ signal)

คำศัพท์ที่จะเจอในส่วนนี้:

  • subscriber = ผู้ฟัง (คนที่ลงทะเบียนรับค่าจาก stream)
  • multicast = ส่งให้ผู้ฟังหลายคนพร้อมกันจากต้นทางเดียว
  • buffer = เก็บค่าที่ผ่านมาไว้
  • late subscriber = คนที่มาสมัครรับทีหลัง (อาจพลาดค่าที่ส่งไปก่อนหน้า)
typescript
// หมายเหตุ: ชื่อตัวแปรลงท้ายด้วย $ เป็น convention ของ RxJS หมายถึงตัวแปรนั้นเป็น Observable หรือ Subject
// emit (ปล่อย/ส่งค่าออกมา) — ใช้ผ่าน .next()

import { Subject, BehaviorSubject, ReplaySubject, AsyncSubject } from 'rxjs';

// Subject — ส่งให้หลายคนพร้อมกัน (multicast), ไม่มีค่าตั้งต้น
const events$ = new Subject<string>();
events$.subscribe(v => console.log('A:', v));
events$.next('hello');           // A: hello
events$.subscribe(v => console.log('B:', v));
// B subscribe หลัง 'hello' ถูก emit ไปแล้ว → B จึงพลาด 'hello'
// 'world' emit หลังจาก B subscribe → ทั้ง A และ B ได้รับ 'world'
events$.next('world');           // A: world / B: world

// BehaviorSubject — มี "ค่าปัจจุบัน" + ส่งให้คนที่มาทีหลังได้ทันที
const user$ = new BehaviorSubject<User | null>(null);
user$.subscribe(v => console.log(v));  // null (ค่าตั้งต้น)
user$.next({ id: 1, name: 'Anna' });
user$.subscribe(v => console.log(v));  // Anna (คนมาทีหลังได้ค่าปัจจุบันทันที)

// ReplaySubject — เก็บค่าย้อนหลัง N ค่าไว้ส่งให้คนที่มาทีหลัง
const messages$ = new ReplaySubject<string>(3);    // เก็บ 3 ค่าหลังสุด
messages$.next('a'); messages$.next('b'); messages$.next('c'); messages$.next('d');
messages$.subscribe(v => console.log(v));   // b, c, d (3 ค่าหลังสุด)

// AsyncSubject — ส่ง "เฉพาะค่าสุดท้าย" ตอน complete (จบสาย) เท่านั้น
const result$ = new AsyncSubject<number>();
result$.next(1); result$.next(2); result$.next(3);
result$.subscribe(v => console.log(v));  // (ยังไม่มีอะไร)
result$.complete();                       // 3 (ค่าสุดท้าย ส่งตอน complete)

ใช้ตัวไหนเมื่อไร

Subjectใช้เมื่อ
Subjectกระจายเหตุการณ์แบบ pub/sub (pub/sub = คนส่งประกาศ คนสนใจสมัครรับ) เช่น click, custom event
BehaviorSubjectstate ที่มีค่าปัจจุบัน (user, config, theme)
ReplaySubjectประวัติย้อนหลังล่าสุด (การแจ้งเตือน, log buffer)
AsyncSubjectผลลัพธ์ของงานที่ทำครั้งเดียว (พบน้อย — ใช้ Promise แทนได้)
typescript
// service เก็บ state ด้วย BehaviorSubject (ทางเลือกแทน signal)
@Injectable({ providedIn: 'root' })
export class CartService {
    private items$ = new BehaviorSubject<CartItem[]>([]);
    
    items = this.items$.asObservable();           // เปิดให้อ่านอย่างเดียว (readonly)
    
    add(item: CartItem) {
        this.items$.next([...this.items$.value, item]);
    }
}

// Angular ยุคใหม่ = นิยมใช้ Signal มากกว่า BehaviorSubject สำหรับเก็บ state
// แต่ BehaviorSubject ยังมีประโยชน์กับ event stream + การต่อท่อ (pipeline) ของ RxJS

11.5. RxJS Interop (การเชื่อมต่อระหว่าง RxJS และ Signal)

ในงานจริงมักผสมทั้งสอง — HTTP เป็น Observable (RxJS) แต่ UI อยากใช้ signal · Angular มีตัวแปลงสองทาง: toSignal() (Observable→signal) และ toObservable() (signal→Observable) ให้ค่อย ๆ ย้ายหรือผสมได้

💡 ส่วนนี้แตะ RxJS — ถ้ายังไม่ได้อ่านบท 6 ดูพอเห็นหน้าตา API ไว้ก่อน แล้วกลับมาอ่านอีกรอบจะเข้าใจชัดขึ้น

Observable → Signal

typescript
// โค้ดนี้ต้องอยู่ใน class ที่มี private http = inject(HttpClient) ประกาศไว้
import { toSignal } from '@angular/core/rxjs-interop';

// จาก observable
const data$ = this.http.get<User[]>('/api/users');
users = toSignal(data$, { initialValue: [] });
html
<!-- ใช้ใน template -->
<div>{{ users().length }}</div>
typescript
// พร้อมจัดการ error
import { catchError, of } from 'rxjs';
// (catchError = ดักจับ error ในสาย Observable / of = สร้าง Observable จากค่าธรรมดา
//  / pipe = ต่อ operator หลายตัวเข้าด้วยกัน — เต็ม ๆ ในบท 6)
users = toSignal(
    this.http.get<User[]>('/api/users').pipe(
        catchError(() => of([]))
    ),
    { initialValue: [] }
);

Signal → Observable

typescript
import { toObservable } from '@angular/core/rxjs-interop';

// (ตัวอย่างนี้สมมติอยู่ใน class ของ component หรือ service)
count = signal(0);
count$ = toObservable(this.count);

// subscribe (สมัครรับค่า)
count$.subscribe(v => console.log(v));

→ ผสม signal + RxJS ได้ — ค่อย ๆ ย้ายทีละส่วน (gradual migration = การค่อย ๆ ย้ายจาก RxJS BehaviorSubject ไปเป็น signal ทีละ service ไม่ต้องเปลี่ยนทีเดียวทั้งหมด)


12. Signal vs RxJS — ใช้อะไรเมื่อไร?

แล้วเมื่อไรใช้อะไร? กฎง่าย ๆ: signal สำหรับ state ของ UI (ค่าปัจจุบัน, ค่าที่คำนวณต่อ) · RxJS สำหรับ stream/async (HTTP, event, debounce, retry) — มักจบที่ HTTP เป็น Observable แล้ว toSignal มาใช้ใน UI:

💡 ถ้ายังไม่รู้จัก operator เหล่านี้ ไม่ต้องกังวล — จะสอนละเอียดในบท 6 ตอนนี้รู้แค่ว่า RxJS เหมาะกับงาน async ซับซ้อนก็พอ

text
Signal (ใช้กับ state ของ UI):
✅ state ของ component
✅ ค่าที่คำนวณต่อแบบ synchronous (computed)
✅ input/output ง่าย ๆ
✅ state ภายในของ UI

RxJS (ใช้กับ stream/async):
✅ การเรียก HTTP
✅ สายเหตุการณ์ (websocket, scroll)
✅ async ซับซ้อน (debounce = หน่วงเวลา, retry = ลองใหม่, switchMap = สลับสายเมื่อมีคำขอใหม่มาแทนของเก่า)
✅ ที่อิงเวลา (interval, timeout)
✅ เรื่องที่ใช้ข้ามทั้งแอป (cross-cutting concerns = งานที่ต้องทำทั่วทั้งแอป เช่น analytics = วิเคราะห์การใช้งาน, logging = บันทึก log)

→ มักจบที่: HTTP เป็น Observable → toSignal → ใช้เป็น signal ใน UI

13. Resource (Angular 19+) — Async Data (ข้อมูลแบบ Asynchronous — ทำงานแบบไม่รอ)

การโหลดข้อมูล async มักต้องจัดการ 3 สถานะเอง: loading / error / data · resource() (Angular 19+) เป็นเครื่องมือใหม่ที่ห่อทั้งสามให้เป็น signal พร้อมใช้ — รวมถึงปุ่ม reload:

typescript
import { resource } from '@angular/core';

@Component({...})
export class UserDetailComponent {
    userId = input.required<number>();
    
    // หมายเหตุ: ตัวอย่างนี้ใช้ fetch() (JavaScript built-in สำหรับเรียก HTTP API)
    // .then(r => r.json()) = แปลง response เป็น JSON (ต้องรู้ JavaScript Promise พื้นฐาน)
    // ในงาน Angular จริงนิยมใช้ HttpClient (บท 6) — ดูเวอร์ชัน rxResource ด้านล่าง
    user = resource({
        request: () => ({ id: this.userId() }),
        loader: ({ request }) => fetch(`/api/users/${request.id}`).then(r => r.json())
    });
}
html
@if (user.isLoading()) {
    <p>Loading...</p>
} @else if (user.error()) {
    <p>Error: {{ user.error()?.message }}</p>
} @else {
    <p>{{ user.value()?.name }}</p>
}

<button (click)="user.reload()">Refresh</button>

→ จัดการสถานะ async ให้ในตัว (loading/error/data)

rxResource — RxJS-based

typescript
import { rxResource } from '@angular/core/rxjs-interop';

user = rxResource({
    request: () => ({ id: this.userId() }),
    loader: ({ request }) => this.http.get<User>(`/api/users/${request.id}`)
});

14. State Patterns (แพตเทิร์นการจัดการ State)

state มี 2 ระดับ: local (อยู่ใน component เดียว) และ shared (หลาย component ใช้ร่วม ผ่าน service) · แพตเทิร์นสำคัญของ shared คือเก็บ signal เป็น private แล้วเปิดให้อ่านผ่าน asReadonly() — แก้ได้เฉพาะผ่าน method ของ service:

Local Component State

typescript
@Component({...})
export class FormComponent {
    name = signal('');
    age = signal(0);
    submitting = signal(false);
    
    isValid = computed(() => 
        this.name().length > 0 && this.age() >= 18
    );
}

Shared via Service

typescript
import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class UserStore {
    private _users = signal<User[]>([]);
    private _selected = signal<User | null>(null);
    
    // เปิดให้คนอื่นอ่านอย่างเดียว (readonly)
    readonly users = this._users.asReadonly();
    readonly selected = this._selected.asReadonly();
    readonly count = computed(() => this._users().length);
    
    // แก้ค่าได้ผ่าน method ของ service เท่านั้น
    setUsers(users: User[]) {
        this._users.set(users);
    }
    
    select(user: User) {
        this._selected.set(user);
    }
    
    add(user: User) {
        this._users.update(users => [...users, user]);
    }
    
    remove(id: number) {
        this._users.update(users => users.filter(u => u.id !== id));
    }
}

→ ห่อหุ้ม (encapsulation = ซ่อนรายละเอียดข้างใน เปิดเฉพาะสิ่งที่จำเป็น — เหมือนรีโมททีวีที่ไม่ต้องรู้ว่าข้างในทำงานยังไง แค่กดปุ่มได้) — เขียนได้เฉพาะข้างใน อ่านได้จากทุกที่

asReadonly

typescript
private _count = signal(0);
readonly count = this._count.asReadonly();

// ฝั่งคนใช้
this.count();       // ✅ อ่านได้
this.count.set(5);  // ❌ compile error — เป็น readonly เขียนไม่ได้

15. Loading State Pattern

แพตเทิร์นมาตรฐานสำหรับโหลดข้อมูล: มี signal 3 ตัว (users, loading, error) แล้วสลับค่าระหว่างเรียก API — template เลือกแสดงตามสถานะ (กำลังโหลด/ผิดพลาด/มีข้อมูล) · ถ้า Angular 19+ ใช้ resource() (ข้อ 13) ย่อให้ได้:

typescript
// HttpClient ต้องลงทะเบียนใน app.config.ts ด้วย provideHttpClient() ก่อน — ดูบท 6
@Injectable({ providedIn: 'root' })
export class UserService {
    private http = inject(HttpClient);
    
    users = signal<User[]>([]);
    loading = signal(false);
    error = signal<Error | null>(null);
    
    async loadUsers() {
        this.loading.set(true);
        this.error.set(null);
        try {
            // firstValueFrom = เปลี่ยน Observable เป็น Promise (เอาค่าแรก) เพื่อใช้กับ async/await — เต็ม ๆ ในบท 6
            const users = await firstValueFrom(this.http.get<User[]>('/api/users'));
            this.users.set(users);
        } catch (e) {
            this.error.set(e as Error);
        } finally {
            this.loading.set(false);
        }
    }
}
html
<button (click)="userService.loadUsers()" [disabled]="userService.loading()">
    Load Users
</button>

@if (userService.loading()) {
    <p>Loading...</p>
} @else if (userService.error()) {
    <p>Error: {{ userService.error()?.message }}</p>
} @else {
    @for (u of userService.users(); track u.id) {
        <div>{{ u.name }}</div>
    }
}

→ หรือใช้ resource() (Angular 19+) — handle ให้


16. NgRx + Signals (Optional)

🚧 ขั้นสูง — ข้ามได้รอบแรก หัวข้อนี้เป็น preview ของ library เสริม NgRx Signal Store ซึ่งจะใช้ตอนแอปใหญ่ (state ซับซ้อน หลายทีม) เท่านั้น · เนื้อหาเต็มอยู่ใน บท 10 — มือใหม่ดูพอเห็นหน้าตา API แล้ว ข้ามไปข้อ 17 (Performance Tips) ก่อนได้

พอแอปใหญ่มาก (state ซับซ้อน หลายทีม) การจัดการ signal กระจัดกระจายเริ่มลำบาก · NgRx Signal Store เป็น library ที่จัด state ให้เป็นที่เป็นทาง (state + computed + methods) โดยไม่มี boilerplate (boilerplate = โค้ดซ้ำ ๆ ที่ต้องเขียนทุกครั้งทั้งที่ไม่ได้ทำอะไรพิเศษ) เยอะแบบ Redux (pattern จัดการ state แบบรวมศูนย์ที่นิยมใช้ใน JavaScript framework หลายตัว — สอนเต็มในบท 10 ไม่ต้องรู้จักก่อนก็ใช้ NgRx Signal Store ได้) — ใช้เมื่อจำเป็นเท่านั้น:

แอปใหญ่ — ใช้ NgRx Signal Store

bash
npm install @ngrx/signals
typescript
import { computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';

export const UserStore = signalStore(
    { providedIn: 'root' },
    withState({
        users: [] as User[],
        loading: false,
        error: null as Error | null
    }),
    withComputed(({ users }) => ({
        count: computed(() => users().length),
        active: computed(() => users().filter(u => u.active))
    })),
    withMethods((store, http = inject(HttpClient)) => ({
        async load() {
            patchState(store, { loading: true });
            try {
                const users = await firstValueFrom(http.get<User[]>('/api/users'));
                patchState(store, { users, loading: false });
            } catch (error) {
                patchState(store, { error: error as Error, loading: false });
            }
        }
    }))
);
typescript
// Component
import { Component, OnInit, inject } from '@angular/core';

export class UserListComponent implements OnInit {
    store = inject(UserStore);
    
    ngOnInit(): void {
        void this.store.load();    // void = ไม่รอ Promise — ถ้า load() ผิดพลาดควรจัดการ error ใน load() เอง
    }
}
html
@if (store.loading()) { <p>Loading</p> }
@for (u of store.users(); track u.id) { ... }
<p>Total: {{ store.count() }}</p>

→ NgRx Signal Store = ทันสมัย เบา (ไม่มี boilerplate เยอะแบบ Redux)


17. Performance Tips (เคล็ดลับเพิ่มประสิทธิภาพ)

signal เร็วอยู่แล้ว แต่มีจุดพลาดที่ทำให้ช้าได้ — เคล็ดลับสำคัญคือ ใช้ computed (ที่ cache ผลให้) แทน getter (ที่คำนวณใหม่ทุกครั้ง) และเลี่ยงคำนวณหนัก ๆ ใน computed:

Computed Cache (การเก็บผลลัพธ์ไว้ใช้ซ้ำ)

typescript
// ✅ ใช้ computed — cache ผลลัพธ์ไว้
total = computed(() => this.items().reduce(...));

// ❌ ไม่ดี — คำนวณใหม่ทุกครั้งที่อ่าน
get total() {
    return this.items().reduce(...);
}

Avoid Heavy Computed (เลี่ยง Computed ที่คำนวณหนักเกิน)

typescript
// ❌ sort หนัก ๆ ทุกครั้งที่วาดใหม่
sorted = computed(() => [...this.items()].sort(complexCompare));

// ✅ ถ้าหนักมากจริง ๆ คำนวณครั้งเดียวแล้ว cache ไว้ข้างนอก

Track Reads (ระวังสิ่งที่ถูกติดตามใน Effect)

typescript
effect(() => {
    // ✅ ติดตามแค่ `count`
    console.log(this.count());
    
    // อย่าอ่าน signal ตัวอื่นถ้าไม่จำเป็น (จะถูกติดตามโดยไม่ตั้งใจ)
});

18. Common Patterns (แพตเทิร์นที่เจอบ่อย)

3 แพตเทิร์น state ที่เจอบ่อยในงานจริง — แบ่งหน้า (pagination), ฟอร์มที่ validate ด้วย computed, และค้นหาแบบหน่วงเวลา (debounce) · ดูเป็นแม่แบบเอาไปปรับใช้ได้:

Pagination (การแบ่งหน้า)

typescript
@Component({...})
export class UserListComponent {
    private userService = inject(UserService);
    
    page = signal(0);
    pageSize = signal(20);
    search = signal('');
    
    users = signal<User[]>([]);
    total = signal(0);
    
    totalPages = computed(() => Math.ceil(this.total() / this.pageSize()));
    
    constructor() {
        // ⚠️ Anti-pattern: เรียก HTTP + เขียน signal กลับใน effect
        // ใช้ได้เพื่อความเรียบง่าย แต่ของจริงควรใช้ rxResource/httpResource (ข้อ 13) แทน
        effect(() => {
            this.loadPage();
        });
    }
    
    async loadPage() {
        const res = await this.userService.list({
            page: this.page(),
            size: this.pageSize(),
            q: this.search()
        });
        this.users.set(res.users);
        this.total.set(res.total);
    }
    
    nextPage() {
        this.page.update(p => p + 1);
    }
}

⚠️ Anti-pattern เตือน: ตัวอย่างข้างบนเรียก HTTP แล้วเขียน signal กลับ "ใน effect" ซึ่งเป็น textbook "computed/effect ไม่ควรทำ side effect" · โค้ดผลิตจริงควรใช้ rxResource() / httpResource() (ข้อ 13) แทน — ที่นี่เขียนแบบนี้เพื่อให้เห็นแนวคิดก่อน

Form with Validation (signal-based)

typescript
@Component({...})
export class FormComponent {
    name = signal('');
    email = signal('');
    age = signal(0);
    
    errors = computed(() => {
        const e: Record<string, string> = {};
        if (!this.name()) e['name'] = 'Required';
        if (!this.email().includes('@')) e['email'] = 'Invalid email';
        if (this.age() < 18) e['age'] = 'Must be 18+';
        return e;
    });
    
    isValid = computed(() => Object.keys(this.errors()).length === 0);
    
    async submit() {
        if (!this.isValid()) return;
        await submitData({
            name: this.name(),
            email: this.email(),
            age: this.age()
        });
    }
}

Debounced Search (การค้นหาแบบหน่วงเวลา)

💡 ตัวอย่างนี้ใช้ RxJS operator (debounceTime, distinctUntilChanged, toObservable) — ถ้ายังไม่ได้อ่านบท 6 อาจดูพอเห็นแนวคิดก่อน · ใช้ toSignal() + takeUntilDestroyed() แทน .subscribe() ตรง ๆ เพื่อกัน memory leak

typescript
import { Component, signal, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { from } from 'rxjs';

@Component({...})
export class SearchComponent {
    private userService = inject(UserService);
    
    query = signal('');
    
    // หน่วงเวลา (debounce) ค่าจาก signal → ค้นหา → แปลงผลกลับเป็น signal
    // ใช้ toSignal() แทน .subscribe() เพื่อให้ Angular cleanup ให้อัตโนมัติเมื่อ component ถูกทำลาย
    results = toSignal(
        toObservable(this.query).pipe(
            debounceTime(300),
            distinctUntilChanged(),
            switchMap(q => from(this.userService.search(q)))  // switchMap = ทิ้งคำขอเก่าเมื่อมีใหม่
        ),
        { initialValue: [] as any[] }
    );
}

ถ้ายังอยากเขียนแบบ .subscribe() ให้ใส่ takeUntilDestroyed() ใน pipe เสมอ — ไม่งั้น subscription จะค้างหลัง component ถูกทำลาย (memory leak = ข้อมูลค้างในหน่วยความจำโดยไม่จำเป็น ทำให้แอปช้าลงเรื่อย ๆ)


19. ⚠️ Common Pitfalls (ข้อผิดพลาดที่เจอบ่อย)

ข้อผิดพลาดเรื่อง signal ที่เจอบ่อย — อันดับ 1 คือ "ลืมใส่วงเล็บ" (count แทน count()) ตารางนี้รวมไว้ครบ:

❌ ที่มักทำผิด✅ ที่ถูก
count (ลืมใส่วงเล็บเรียก)count()
count.set(c => c+1)count.update(c => c+1) หรือ set(value)
เขียน signal ใน effect ที่ทำ loopใช้ untracked() ครอบ หรือเปลี่ยนเป็น computed() (Angular 19+: allowSignalWrites deprecated แล้ว)
อ่าน signal นอก reactive contextทำครั้งเดียวได้ — แต่จะไม่ถูกติดตาม
property ธรรมดา + สั่ง cdr.markForCheck เองใช้ signal
ลืม readonlyใช้ signal.asReadonly() ตอนเปิดให้คนอื่นอ่าน
computed หนักเกินถ้าจำเป็นให้ memoize (cache) ไว้ข้างนอก
ผสม signal + RxJS มั่ว ๆเลือกตัวหลักให้ชัด — ใช้ interop เท่าที่จำเป็น

20. Migration from Property to Signal

ถ้ามีโค้ดเก่าที่ใช้ property ธรรมดา อยากเปลี่ยนเป็น signal ทำได้ 4 ขั้นตามนี้ (เปลี่ยน = เป็น signal(), เพิ่มวงเล็บตอนอ่าน, ใช้ .set()/.update() ตอนเขียน):

typescript
// ก่อน
export class Old {
    count = 0;
    
    template() {
        // {{ count }}
    }
    
    increment() {
        this.count++;
    }
}

// หลัง
export class New {
    count = signal(0);
    
    template() {
        // {{ count() }}
    }
    
    increment() {
        this.count.update(c => c + 1);
    }
}
text
สิ่งที่เปลี่ยน:
1. = → signal()
2. {{ x }} → {{ x() }}        (เพิ่มวงเล็บตอนอ่าน)
3. this.x = y → this.x.set(y)
4. this.x++ → this.x.update(v => v + 1)

21. ตัวอย่างเต็ม — Task Manager

รวมทุกอย่างในบทเป็นแอปจริง — Task Manager ที่เพิ่ม/ติ๊ก/กรอง/บันทึกลง localStorage · ใช้ signal (state) + computed (นับ active/done + กรอง) + effect (auto-save) ครบวงจร ลองอ่านแล้วโยงกลับว่าแต่ละส่วนคือ concept ไหน:

typescript
import { Component, signal, computed, effect } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Task {
    id: number;
    title: string;
    priority: 'low' | 'medium' | 'high';
    done: boolean;
}

@Component({
    selector: 'app-tasks',
    standalone: true,
    imports: [FormsModule],    // FormsModule ให้ใช้ [(ngModel)] ได้ — two-way binding สำหรับ form element
    template: `
        <div class="tasks-app">
            <h1>Tasks</h1>
            
            <!-- เพิ่มงานใหม่ -->
            <div class="add">
                <input [(ngModel)]="newTitle" placeholder="Title">
                <select [(ngModel)]="newPriority">
                    <option value="low">Low</option>
                    <option value="medium">Medium</option>
                    <option value="high">High</option>
                </select>
                <button (click)="add()" [disabled]="!newTitle">Add</button>
            </div>
            
            <!-- ตัวกรอง -->
            <div class="filter">
                <button 
                    [class.active]="filter() === 'all'" 
                    (click)="filter.set('all')"
                >All ({{ tasks().length }})</button>
                <button 
                    [class.active]="filter() === 'active'" 
                    (click)="filter.set('active')"
                >Active ({{ activeCount() }})</button>
                <button 
                    [class.active]="filter() === 'done'" 
                    (click)="filter.set('done')"
                >Done ({{ doneCount() }})</button>
            </div>
            
            <!-- รายการ -->
            <ul>
                @for (task of filtered(); track task.id) {
                    <li [class.done]="task.done" [class]="'p-' + task.priority">
                        <input 
                            type="checkbox" 
                            [checked]="task.done"
                            (change)="toggle(task.id)"
                        >
                        <span>{{ task.title }}</span>
                        <span class="priority">{{ task.priority }}</span>
                        <button (click)="remove(task.id)">×</button>
                    </li>
                } @empty {
                    <p>No tasks</p>
                }
            </ul>
        </div>
    `,
    styles: `
        .tasks-app { max-width: 500px; margin: 2rem auto; padding: 16px; }
        .add, .filter { display: flex; gap: 8px; margin-bottom: 16px; }
        .filter button.active { background: #1976d2; color: white; }
        .done { text-decoration: line-through; opacity: 0.5; }
        .p-high { border-left: 4px solid red; }
        .p-medium { border-left: 4px solid orange; }
        .p-low { border-left: 4px solid #999; }
        li { display: flex; gap: 8px; padding: 8px; }
    `
})
export class TasksComponent {
    // State (สถานะ)
    tasks = signal<Task[]>([]);
    filter = signal<'all' | 'active' | 'done'>('all');
    // newTitle เป็น property ธรรมดา เพราะ [(ngModel)] ผูก two-way โดยตรงได้ — model() signal ก็ทำได้แต่ยุ่งกว่าสำหรับ local form
    newTitle = '';
    newPriority: Task['priority'] = 'medium';
    nextId = 1;
    
    // ค่าที่คำนวณต่อ (derived)
    activeCount = computed(() => this.tasks().filter(t => !t.done).length);
    doneCount = computed(() => this.tasks().filter(t => t.done).length);
    
    filtered = computed(() => {
        const tasks = this.tasks();
        const f = this.filter();
        if (f === 'active') return tasks.filter(t => !t.done);
        if (f === 'done') return tasks.filter(t => t.done);
        return tasks;
    });
    
    constructor() {
        // โหลดจาก localStorage — เช็ค typeof กัน SSR (server-side rendering ไม่มี localStorage)
        if (typeof localStorage !== 'undefined') {
            const saved = localStorage.getItem('tasks');
            if (saved) {
                try {
                    const parsed: Task[] = JSON.parse(saved);
                    this.tasks.set(parsed);
                    this.nextId = Math.max(0, ...parsed.map(t => t.id)) + 1;
                } catch {
                    // JSON เสียหาย — ใช้ค่าเริ่มต้น [] (ไม่เขียนทับ localStorage จนกว่าผู้ใช้จะเปลี่ยน)
                }
            }
        }
        
        // บันทึกอัตโนมัติ — effect รันรอบแรกหลัง load เสร็จ จึงเขียนค่าที่เพิ่งโหลด (no-op) ปลอดภัย
        effect(() => {
            const data = this.tasks();
            if (typeof localStorage !== 'undefined') {
                localStorage.setItem('tasks', JSON.stringify(data));
            }
        });
    }
    
    add() {
        if (!this.newTitle.trim()) return;
        this.tasks.update(tasks => [
            ...tasks,
            {
                id: this.nextId++,
                title: this.newTitle,
                priority: this.newPriority,
                done: false
            }
        ]);
        this.newTitle = '';
    }
    
    toggle(id: number) {
        this.tasks.update(tasks =>
            tasks.map(t => t.id === id ? { ...t, done: !t.done } : t)
        );
    }
    
    remove(id: number) {
        this.tasks.update(tasks => tasks.filter(t => t.id !== id));
    }
}

→ รวมกัน: signal, computed, effect, การบันทึกค่า (persistence)


22. Checkpoint

signal ต้องลงมือถึงจะคล่อง — ทำ 5 ข้อนี้ไล่จากง่าย (basic signal) ไปยาก (RxJS interop) ด้วยตัวเอง:

🛠️ Checkpoint 2.1 — Basic Signal (signal พื้นฐาน)

  • สร้าง Component ที่มี count, step, total
  • total = computed(() => count() * step()) — อย่าลืมใส่วงเล็บเวลาอ่านค่า signal ใน computed
  • มีปุ่มอัปเดต count และ step
  • ตรวจสอบว่า total คำนวณใหม่อัตโนมัติเมื่อ count หรือ step เปลี่ยน

🛠️ Checkpoint 2.2 — LocalStorage Sync (บันทึกลง localStorage)

  • สร้าง TODO list ด้วย signal
  • บันทึกอัตโนมัติลง localStorage ผ่าน effect
  • โหลดข้อมูลตอนเริ่ม
  • ตรวจสอบว่า reload หน้าแล้วข้อมูลยังอยู่

🛠️ Checkpoint 2.3 — Shared State (state ร่วมกัน)

  • สร้าง CartService ที่มี items signal
  • 2 components: ProductList + CartIcon
  • เพิ่มสินค้า → ไอคอนตะกร้าอัปเดตเอง
  • ใช้ asReadonly() + แก้ค่าผ่าน method เท่านั้น

🛠️ Checkpoint 2.4 — Computed Chain (computed ต่อกัน)

  • สร้าง E-commerce cart
  • items, subtotal, ภาษี (10%), shipping (ฟรีถ้ายอดเกิน $50), total
  • คำนวณทั้งหมดด้วย computed
  • เปลี่ยน item → ทุกค่าอัปเดตอัตโนมัติ

🛠️ Checkpoint 2.5 — RxJS Interop (ผสม RxJS กับ signal)

  • HTTP request ที่คืน Observable
  • แปลงเป็น signal ด้วย toSignal()
  • ใช้ใน template
  • จัดการสถานะ loading/error

23. สรุปบท

ทบทวนแกนของบทนี้ — signal คือหัวใจ reactivity ของ Angular ยุคใหม่ ถ้าเข้าใจ signal/computed/effect แน่นแล้ว ที่เหลือต่อยอดง่าย:

Signal = ตัว reactive ของ Angular (Angular 17+)
signal(value) → อ่านด้วย (), เขียนด้วย .set() / .update()
Computed = signal คำนวณ — lazy (ขี้เกียจ คำนวณเมื่อมีคนอ่าน) + memoized (จำผลไว้)
Effect = side effect (งานข้างเคียง) รันตอนเริ่ม + ทุกครั้งที่ signal ที่อ่านอยู่เปลี่ยน
untracked = อ่านค่าโดยไม่จดทะเบียนติดตาม
input() + output() + model() = component API แบบ signal
toSignal / toObservable = ตัวแปลงสองทางระหว่าง signal และ RxJS Observable
resource (Angular 19+) = โหลดข้อมูล async พร้อม state loading/error ในตัว
✅ ใช้ signal สำหรับ state ของ UI, ใช้ RxJS สำหรับ stream/async
✅ แพตเทิร์น shared state: service ที่มี private signal + asReadonly()
NgRx Signal Store = ตัวจัดการ state สำหรับแอปขนาดใหญ่
✅ การย้ายโค้ดเก่า: property → signal — ใช้ API เดียวกันทั้งระบบ reactivity


← บทที่ 1 | บทที่ 3 → Services + DI