โหมดมืด
บทที่ 2 — Signals และ State Management
หลังจบบท คุณจะ:
- เข้าใจ 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 ยังอัปเดตไม่ครบtypescriptconst 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()typescriptprivate 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-devtypescript
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 |
BehaviorSubject | state ที่มีค่าปัจจุบัน (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) ของ RxJS11.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 ใน UI13. 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/signalstypescript
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