โหมดมืด
บทที่ 6 — HTTP + RxJS
หลังจบบท คุณจะ:
- ใช้ HttpClient เรียก REST API (REST API = ช่องทางมาตรฐานที่เว็บแอปใช้คุยกับ backend/server ผ่าน HTTP เช่น ขอข้อมูล user, บันทึกข้อมูล)
- เข้าใจ RxJS — Observable, Operator (จะอธิบายทั้งสองคำในบทนี้)
- ใช้ Interceptor (auth, error, retry)
- แปลง Observable → Signal (จะอธิบายทั้งสองคำในบทนี้)
ก่อนอ่าน: ควรผ่าน บทที่ 2 (signals) + บทที่ 3 (services) มาก่อน · บทนี้คือการ "คุยกับ backend" — เกือบทุกแอปต้องใช้ · บทนี้จะใช้
inject()และ@Injectableจากบทที่ 3 บ่อย ถ้าลืมสามารถกลับไปทบทวนได้
1. Setup HttpClient
HttpClient คือเครื่องมือมาตรฐานของ Angular สำหรับเรียก API — ต้องเปิดใช้ครั้งเดียวด้วย provideHttpClient() ใน config ก่อน แล้ว inject ไปใช้ที่ service ไหนก็ได้:
เวอร์ชันขั้นต่ำ (เริ่มจากแบบนี้ก่อน — copy ได้เลย ไม่มี ReferenceError):
typescript
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
// withFetch() = ใช้ Fetch API (มาตรฐานเบราว์เซอร์รุ่นใหม่) แทน XHR (XMLHttpRequest แบบเก่า)
// สำหรับมือใหม่: ไม่ต้องเข้าใจความต่าง ใส่ withFetch() ไว้เสมอเป็น default ที่ดี
// SSR และ zoneless คือฟีเจอร์ขั้นสูงที่จะอธิบายในบทหลัง — ตอนนี้ใส่ withFetch() ไว้เฉย ๆ ก่อนได้เลย
withFetch()
)
]
};เวอร์ชันสำหรับงานจริง (เพิ่ม withInterceptors([...]) — โหมดแนะนำใน Angular 15+):
⚠️ อย่า copy โค้ดนี้จนกว่าจะอ่าน section 9 เสร็จ — ไฟล์
./interceptorsยังไม่มีจนกว่าจะทำ section 9 ถ้า copy ตอนนี้จะ compile error ทันที
typescript
// app.config.ts (เวอร์ชันเต็ม)
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { authInterceptor, errorInterceptor } from './interceptors'; // 👈 เขียนใน section 9
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withFetch(),
// withInterceptors([...]) = ลงทะเบียน interceptor แบบ function (โหมดใหม่ Angular 15+)
// ทั้ง authInterceptor + errorInterceptor เขียนใน section 9 ของบทนี้
withInterceptors([authInterceptor, errorInterceptor])
)
]
};📝 ถ้ายังไม่ได้อ่าน section 9 — เริ่มจาก เวอร์ชันขั้นต่ำ ข้างบนก่อน (ไม่มี interceptor) · พออ่าน section 9 เสร็จค่อยกลับมาเพิ่ม
withInterceptors([...])
🎯
withFetch()ปี 2026 เป็น recommended default:
- ทำงานกับ SSR + Edge runtime (= edge server เช่น Cloudflare/Vercel ที่กระจายเซิร์ฟเวอร์ไปใกล้ผู้ใช้) ได้
- รองรับ zoneless อย่างสมบูรณ์
- ใช้ Streams API ส่ง download progress ได้แบบ chunked (= ทยอยส่งเป็นชิ้น ๆ ระหว่างทาง ไม่ต้องรอครบก่อน)
- default ของ XHR ยังใช้ได้แต่ไม่แนะนำสำหรับโค้ดใหม่
typescript
// เรียกใช้ได้จากทุกที่
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
// inject() = ขอให้ Angular จัดหาของให้ (Dependency Injection) แทนการสร้างเอง
// ดูรายละเอียดเรื่อง inject() + DI ใน [บทที่ 3](03-services-and-di.md)
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUsers() {
return this.http.get<User[]>('/api/users');
}
}→ provideHttpClient() = เปิดใช้งาน HttpClient (Angular 15+)
1.1 HttpContext — Per-Request Flags (ข้ามบาง interceptor)
⏭️ ขั้นสูง — มือใหม่ข้ามไป section 2 ก่อนได้ กลับมาอ่านหลังเรียน Interceptor (section 9) แล้ว · ส่วนนี้สำหรับเคสที่ต้องการบอกให้ interceptor "ข้าม" บาง request เป็นครั้งคราว
ปกติ interceptor จะ run ทุก request — แต่บางครั้งอยาก skip (เช่น refresh token endpoint (= API ที่ขอ token ใหม่) ห้ามใส่ Authorization header เพราะ token หมดอายุ; public endpoint (= API ที่เปิดให้ทุกคนเรียก ไม่ต้อง login) ห้าม retry):
typescript
import { HttpContextToken, HttpContext } from '@angular/common/http';
// 📝 หมายเหตุ: คำว่า "token" ใน HttpContextToken เป็นคนละความหมายกับ "auth token"
// ตัวนี้คือ "กุญแจ" ของระบบ DI (Dependency Injection) ใช้บอก interceptor ว่า request นี้มี flag อะไร
// ไม่ใช่ token ที่ใช้ login
// 1. ประกาศ token
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
export const SKIP_RETRY = new HttpContextToken<boolean>(() => false);
// 2. ฝั่ง caller — ใส่ context
this.http.post('/auth/refresh', body, {
context: new HttpContext().set(SKIP_AUTH, true)
});
// 3. ฝั่ง interceptor — เช็คก่อนทำงาน
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(SKIP_AUTH)) return next(req); // ข้าม
// ปกติ — ใส่ token
const auth = inject(AuthService);
return next(req.clone({ setHeaders: { Authorization: `Bearer ${auth.token()}` }}));
};💡 ใช้แก้ปัญหาจริง (pain point): refresh token endpoint (ไม่ต้องใส่ token), public health check (ไม่ต้อง retry), file upload (ไม่ต้อง gzip)
2. HTTP Methods
HTTP มี "กริยา" ตามชนิดงาน — get (อ่าน), post (สร้าง), put/patch (แก้), delete (ลบ) · ใส่ <Type> เพื่อบอกชนิดข้อมูลที่จะได้กลับ และส่ง option (params/headers) เพิ่มได้:
📝
<User>หรือ<User[]>ใน angle bracket คือการบอก TypeScript ว่าข้อมูลที่จะได้กลับมามีรูปร่างแบบ User — Angular ใช้ข้อมูลนี้ช่วยตรวจ type ให้อัตโนมัติ (เรียกว่า generic type)
typescript
// GET
this.http.get<User[]>('/api/users');
this.http.get<User>(`/api/users/${id}`);
// POST
this.http.post<User>('/api/users', { name, email });
// PUT
this.http.put<User>(`/api/users/${id}`, user);
// PATCH
this.http.patch<User>(`/api/users/${id}`, { name });
// DELETE
this.http.delete(`/api/users/${id}`);
// พร้อม option
// 📝 option พวก observe/responseType ใช้กรณีพิเศษเท่านั้น — ตอนเริ่มไม่ต้องใส่ ใช้ default ได้
this.http.get<User[]>('/api/users', {
params: { page: '1', size: '20' },
headers: { 'X-Custom-Header': 'value' },
observe: 'response', // จะรับอะไร: 'body' (เนื้อ default) | 'response' (ทั้ง response รวม headers) | 'events'
responseType: 'json' // ชนิดข้อมูล: 'json' (default) | 'text' | 'blob' | 'arraybuffer'
});3. HttpParams + HttpHeaders
ส่ง query string (?page=1&size=20) และ header ไปกับ request ได้ — แบบ object ง่าย ๆ หรือใช้ HttpParams (immutable = แก้ไม่ได้ เหมาะกับเงื่อนไขซับซ้อน เช่นใส่ param เฉพาะเมื่อมีค่า):
📝 immutable vs mutable: ถ้าเรียก
.set()บน HttpParams จะไม่แก้ของเดิม — จะคืน object ใหม่ให้ ต่างจาก array.push() ที่แก้ของเดิมตรง ๆ (mutable) ดังนั้นต้องรับค่าที่คืนกลับไว้เสมอ:params = params.set(...)
typescript
import { HttpParams, HttpHeaders } from '@angular/common/http';
// แบบ object (ง่าย)
this.http.get<User[]>('/api/users', {
params: { page: 1, size: 20, q: 'angular' }
});
// HttpParams (แก้ไม่ได้/immutable, เหมาะกรณีซับซ้อน)
// 📝 Angular 14+ — .set() รับ string | number | boolean ได้เลย ไม่ต้องแปลงเป็น string เอง
let params = new HttpParams()
.set('page', 1)
.set('size', 20);
if (search) {
params = params.set('q', search); // ใส่ param เฉพาะเมื่อมีค่า
}
this.http.get<User[]>('/api/users', { params });
// ใส่หลายค่าใน key เดียวกัน
params = params.append('tag', 'js').append('tag', 'ts');
// → ?tag=js&tag=ts4. Observable Basics
HttpClient ไม่คืนค่าตรง ๆ แต่คืน Observable — และมันจะ "ยังไม่ทำงาน" จนกว่าจะ .subscribe() (ต่างจาก Promise ที่เริ่มทันที)
📝 Promise คืออะไร? Promise คือวิธีที่ JavaScript รอผลลัพธ์จากงาน async — เช่น รอการ fetch ข้อมูล — Promise ส่งค่ากลับมาครั้งเดียวแล้วจบ ยกเลิกระหว่างทางไม่ได้ · Observable ต่างจาก Promise ตรงที่ส่งได้หลายค่าตามเวลาและยกเลิกได้
🔍 เปรียบเทียบ: Observable เหมือน การสมัครรับนิตยสาร — ตราบใดที่ยังไม่สมัคร (subscribe) ก็ยังไม่มีเล่มส่งมา · พอสมัครแล้วเล่มจะทยอยส่งมาเรื่อย ๆ (emit หลายค่าตามเวลาได้) และยกเลิกการสมัคร (unsubscribe) ได้ · ส่วน Promise เหมือนสั่งของชิ้นเดียวที่ส่งมาครั้งเดียวจบ ยกเลิกไม่ได้
ลองแมปคำศัพท์ RxJS กับอุปมานิตยสารทีละคำ ก่อนที่จะเจอโค้ดจริง — อ่านทีละบรรทัด ไม่ต้องรีบ:
- subscribe = กดปุ่มสมัครสมาชิก (ตอนนี้นิตยสารถึงจะเริ่มส่ง)
- emit = นิตยสารเดินทางมาถึงหน้าบ้านคุณ ครั้งแล้วครั้งเล่าตลอดสัญญา
- unsubscribe = โทรยกเลิกสมาชิก (หยุดส่ง หยุดจ่าย)
- pipe = ตู้ไปรษณีย์ที่มีตะแกรงกรอง — นิตยสารต้องผ่านตรงนี้ก่อนถึงมือคุณ (คุณวางกฎว่าจะเอาเล่มไหน จะตัดหน้าไหนทิ้ง)
- operator = กฎแต่ละข้อในตะแกรง เช่น "เอาเฉพาะเล่มที่มีหน้าเกิน 100 หน้า" หรือ "แปลงชื่อคอลัมน์ก่อนอ่าน"
- lazy = นิตยสารจะไม่ถูกพิมพ์เลยจนกว่าจะมีคนสมัครสมาชิก — ต่างจากพิซซ่า (Promise) ที่พอสั่งแล้วเตาอบก็ทำงานทันที
📖 สัญลักษณ์ RxJS ที่จะเจอ (กางก่อน):
$ท้ายชื่อตัวแปร (เช่นusers$) = convention ตั้งชื่อบอกว่าเป็น Observable — ไม่ใช่ syntax พิเศษ ลบทิ้งก็ยังทำงาน แต่ใส่ไว้ช่วยให้คนอ่านรู้.pipe(op1, op2, ...)= "ที่เสียบ operator" ของ Observable — เอา operator มาต่อกันคล้าย array method chain (.map().filter()) ที่รู้จักอยู่แล้ว เพียงแต่ทำงานกับข้อมูลที่ไหลมาเรื่อย ๆ แทนที่จะเป็น array ครั้งเดียว.subscribe(...)= "ฟังค่า" — ต้องเรียกเสมอ ไม่งั้น Observable ไม่ทำงาน- operator = ฟังก์ชันที่ทำงานกับ Observable เช่น
map,filter,switchMap,debounceTime
typescript
import { Observable } from 'rxjs';
// HttpClient คืนค่าเป็น Observable
const users$ = this.http.get<User[]>('/api/users');
// subscribe เพื่อรับค่า (ก่อน subscribe จะยังไม่ยิง request)
users$.subscribe({
next: (users) => console.log(users), // ได้ค่า
error: (err) => console.error(err), // เกิด error
complete: () => console.log('done') // จบสาย
});
// เขียนสั้น (รับเฉพาะค่า)
users$.subscribe(users => console.log(users));Observable vs Promise
text
Promise:
- ส่งผลครั้งเดียวจบ
- eager — เริ่มทำงานทันที
- ยกเลิกไม่ได้
Observable:
- ส่งได้หลายค่าตามเวลา
- lazy (ขี้เกียจ — รอจนมีคนเรียก) — เริ่มทำงานเมื่อมีคน subscribe
- ยกเลิกได้ (unsubscribe)
- มี operator เยอะ (map, filter, debounce, ...)
(eager = เริ่มทำงานทันที · lazy = รอจนมีคนเรียก/subscribe ก่อน)5. Common RxJS Operators
Operator คือ "ขั้นตอนแปรรูป" ข้อมูลระหว่างทางก่อนถึงผู้รับ — ต่อกันด้วย .pipe() เหมือนสายพานโรงงาน · ตัวที่ใช้บ่อย: map (แปลงค่า), filter (กรอง), tap (แอบทำ side effect = งานที่ไม่เกี่ยวกับการแปลงค่า เช่น log, analytics, อัปเดต state ภายนอก — แยกออกจาก map เพื่อให้โค้ดอ่านง่ายขึ้น), catchError (ดักerror), switchMap (เปลี่ยนไปสตรีมใหม่ เช่น search), debounceTime (หน่วงเวลา):
typescript
// RxJS 7+ (Angular 15+) import ได้จาก 'rxjs' โดยตรง — ไม่ต้อง 'rxjs/operators' อีกต่อไป
// ตรวจสอบเวอร์ชัน RxJS ล่าสุดที่ Angular เวอร์ชันคุณต้องการในเอกสารทางการก่อนใช้งานจริง
import { map, filter, tap, catchError, switchMap, debounceTime, distinctUntilChanged, of } from 'rxjs';
// pipe — ต่อ operator เป็นทอด ๆ
this.http.get<User[]>('/api/users').pipe(
map(users => users.filter(u => u.active)), // แปลงค่า
tap(users => console.log('got', users.length)), // side effect (ไม่แปลงค่า แค่แอบทำอะไรข้าง ๆ)
catchError(err => of([])) // error → ใช้ค่าสำรองแทน (of = สร้าง Observable จากค่า)
).subscribe(users => /* เขียนโค้ดที่ต้องทำกับ users ตรงนี้ เช่น this.users.set(users) */);Transform — map
typescript
this.http.get<User[]>('/api/users').pipe(
map(users => users.map(u => ({
...u,
displayName: u.firstName + ' ' + u.lastName
})))
);Filter
typescript
this.http.get<User[]>('/api/users').pipe(
map(users => users.filter(u => u.age >= 18))
);Side Effect — tap
typescript
this.http.post<User>('/api/users', data).pipe(
tap(user => console.log('created', user)),
tap(() => this.analytics.track('user_created'))
);Error — catchError
typescript
import { of, throwError } from 'rxjs';
this.http.get<User[]>('/api/users').pipe(
catchError(err => {
if (err.status === 404) {
return of([]); // ใช้ค่าว่างแทน
}
return throwError(() => err); // โยน error ต่อ
})
);Switch — switchMap
🔴 อย่า template-string user input ลง URL ตรง ๆ — ผู้ใช้พิมพ์
&,?,หรือไทย → URL พัง / query injection (= ผู้ใช้แอบแทรกข้อความพิเศษลง URL ทำให้พฤติกรรมเปลี่ยนหรือเข้าถึงข้อมูลที่ไม่ควรเห็น) ได้ ใช้encodeURIComponent()หรือHttpParams(ดีกว่าเพราะ encode + type-safe):ตัวอย่าง URL ที่พังจริง — ถ้าผู้ใช้พิมพ์คำค้น
cats & dogsแล้วเราต่อ string ตรง ๆ แบบ`/api/search?q=${q}`จะได้ URL ว่า/api/search?q=cats & dogs— เครื่องหมาย&กลายเป็นตัวแบ่ง parameter ใหม่ทันที ทำให้ server มองว่าเรากำลังส่ง 2 parameter แยกกัน (q=catsกับdogs) แทนที่จะเป็นคำค้นเดียวcats & dogstypescript// ✅ HttpParams (recommended) this.http.get<User[]>('/api/search', { params: new HttpParams().set('q', q) })
typescript
// ผู้ใช้พิมพ์ → ค้นหา (ยกเลิกคำค้นก่อนหน้า)
// this.search คือ FormControl จาก Angular Forms (ดูบทที่ 5)
// .valueChanges คือ Observable ที่ emit ทุกครั้งที่ผู้ใช้พิมพ์ค่าใหม่
this.search.valueChanges.pipe(
debounceTime(300), // หน่วง 300ms รอให้พิมพ์เสร็จก่อน
distinctUntilChanged(), // ข้ามถ้าค่าเหมือนเดิม
// ✅ ใช้ HttpParams ตามที่เตือนใน box ข้างบน — safe by default
switchMap(q => this.http.get<User[]>('/api/search', { params: new HttpParams().set('q', q) }))
).subscribe(users => this.results.set(users));→ พอมีค่าใหม่เข้ามา จะยกเลิก HTTP request อันก่อนหน้าทิ้ง
Flatten Operators ตัวอื่น (operator ที่ "ยิง Observable ซ้อนใน Observable") — Flatten = แผ่/รีด Observable ซ้อนกันให้เหลือสายเดียว
สมมุติคุณเปิดแอปสั่งอาหารแล้วเปลี่ยนใจสั่งหลายเมนูติดกัน — แต่ละเมนูคือ HTTP request หนึ่งอัน ทั้ง 4 operator ต่อไปนี้ต่างกันที่ "จะทำยังไงกับออเดอร์ที่ยังรออยู่" (ดูรายละเอียดเต็มในตารางด้านล่าง):
- switchMap = เปลี่ยนใจสั่งเมนูใหม่ → ยกเลิกเมนูก่อนหน้าทันที เอาแต่อันล่าสุด
- mergeMap = สั่งทุกเมนูพร้อมกันเลยโดยไม่รอกัน
- concatMap = รอเมนูแรกมาวางบนโต๊ะก่อน แล้วค่อยส่งออเดอร์ต่อไป
- exhaustMap = กำลังรอเมนูแรกอยู่ ไม่รับออเดอร์ใหม่จนกว่าจะเสิร์ฟเสร็จ
ทั้ง 4 ตัวรับค่าเข้ามาแล้วไปเรียก Observable ใหม่ (เช่นยิง HTTP) ต่างกันที่ "จัดการกับอันที่ยังค้างอยู่ยังไง" — เลือกผิดตัวจะเกิด bug ที่เจอยาก:
| Operator | ทำอะไร | ใช้กับสถานการณ์ |
|---|---|---|
switchMap | มีค่าใหม่มา → ยกเลิกอันเก่า ทันที เอาแต่อันล่าสุด | ค้นหา (search-as-you-type), เปลี่ยนหน้า — อยากได้แค่ผลล่าสุด อันเก่าทิ้งได้ |
mergeMap | ยิงพร้อมกันทุกอัน (ขนาน) ไม่รอกัน | อัปโหลดหลายไฟล์พร้อมกัน — ลำดับผลไม่สำคัญ |
concatMap | ต่อทีละอันตามลำดับ อันก่อนเสร็จค่อยเริ่มอันถัดไป | บันทึกหลายรายการที่ต้องเรียงลำดับ — กันสลับคิว |
exhaustMap | กำลังทำอันแรกอยู่ → เมินค่าใหม่ จนกว่าอันแรกจะเสร็จ | ปุ่ม login/submit ที่กดรัว ๆ — กันยิงซ้ำซ้อน |
🐛 bug จริงที่ใช้ผิดตัว — ตัวอย่าง search-as-you-type:
User พิมพ์: "a" → ยิง /search?q=a (ใช้เวลา 800ms) User พิมพ์: "ab" → ยิง /search?q=ab (ใช้เวลา 200ms) ❌ ถ้าใช้ mergeMap: /search?q=ab เสร็จก่อน → แสดงผล "ab" /search?q=a เสร็จทีหลัง → ทับด้วยผลเก่า "a" ❌ ✅ ถ้าใช้ switchMap: พอ "ab" มา → cancel request "a" ทันที เห็นแต่ผลของ "ab" ที่ถูกต้อง ✅
6. Combining Observables
บางครั้งต้องรวมหลาย API เข้าด้วยกัน — เช่น หน้า profile ที่ต้องโหลดข้อมูล user + posts + friends พร้อมกัน · forkJoin (ยิงพร้อมกันแล้วรอครบทุกอัน เหมาะโหลดข้อมูลหน้า detail หลายส่วน), combineLatest (emit ใหม่เมื่อ source ไหนเปลี่ยน เหมาะ filter+search), zip (จับคู่ทีละค่า):
ก่อนดูโค้ด ลองจินตนาการสถานการณ์ชีวิตจริงของแต่ละตัว:
- forkJoin = นัดเพื่อน 3 คนไปกินข้าว แต่ตกลงกันว่าจะเข้าร้านก็ต่อเมื่อทุกคนมาถึงพร้อมกัน (ถ้าคนใดคนหนึ่งไม่มาเลย ทั้งกลุ่มก็ไม่เข้าร้าน — ใช้โหลดข้อมูลหน้า detail ที่ต้องการทุกส่วนพร้อมกันถึงจะแสดงผลได้)
- combineLatest = กระดานคะแนนสดในสนามกีฬา — ทันทีที่ผู้เล่นคนไหนก็ตามทำคะแนนได้ กระดานอัปเดตทันที แสดงคะแนนล่าสุดของทุกคน (ใช้ดีกับ filter + search ที่ต้องการ "ค่าล่าสุดของทุกเงื่อนไข" ทุกครั้งที่อะไรก็ตามเปลี่ยน)
- zip = จับคู่ถุงเท้า — ถุงซ้ายต้องรอถุงขวา ออกเป็นคู่ทีละคู่เรื่อย ๆ ค่าที่ 1 จับกับค่าที่ 1, ค่าที่ 2 จับกับค่าที่ 2 (ใช้กรณีที่สองสตรีมต้องสัมพันธ์กันเป็นคู่ เช่นคำถาม-คำตอบที่ต้องจับคู่ตามลำดับ)
forkJoin — รอครบทุกสาย (Parallel + Wait All)
typescript
import { forkJoin } from 'rxjs';
forkJoin({
user: this.http.get<User>(`/api/users/${id}`),
posts: this.http.get<Post[]>(`/api/users/${id}/posts`),
comments: this.http.get<Comment[]>(`/api/users/${id}/comments`)
}).subscribe(({ user, posts, comments }) => {
console.log(user, posts, comments);
});→ รอครบทั้ง 3 → ส่งครั้งเดียวพร้อมผลทุกอัน
combineLatest — ค่าล่าสุดของแต่ละสาย (Latest of Each)
typescript
import { combineLatest, HttpParams } from 'rxjs';
// this.userId$ และ this.filter$ คือ Observable ที่ component นี้มีอยู่แล้ว
// (เช่น มาจาก FormControl.valueChanges หรือ toObservable(signal))
combineLatest([
this.userId$,
this.filter$
]).pipe(
switchMap(([id, filter]) =>
// ✅ ใช้ HttpParams แทน template string เพื่อ encode อัตโนมัติ (ดูคำเตือนใน section 5)
this.http.get<Post[]>('/api/users/' + id + '/posts', {
params: new HttpParams().set('filter', filter)
})
)
).subscribe(posts => /* เขียนโค้ดจัดการ posts ตรงนี้ */);→ ส่งค่าใหม่ทุกครั้งที่ source ตัวใดตัวหนึ่งเปลี่ยน (พร้อมค่าล่าสุดของทุกตัว)
zip — จับคู่ทีละค่า (Pair Up)
typescript
import { zip } from 'rxjs';
zip(stream1$, stream2$).subscribe(([a, b]) => /* เขียนโค้ดที่ต้องทำกับ a, b ตรงนี้ */);→ จับคู่ค่าที่ 1 กับค่าที่ 1, ค่าที่ 2 กับค่าที่ 2 ไปเรื่อย ๆ
7. Convert Observable → Signal
HTTP เป็น Observable แต่ UI ยุคใหม่อยากใช้ signal — ทำไมต้องแปลง? ในบทที่ 2 เราใช้ signal() กับ template ตรง ๆ เช่น {{ count() }} ได้ทันที แต่ HttpClient คืน Observable ซึ่งใช้แบบนั้นใน template ไม่ได้ — มันเป็นคนละระบบกัน toSignal() คือสะพานเชื่อม: เอา Observable มาห่อแล้วได้ signal ออกมา ใช้ใน template ได้เหมือนปกติ และ Angular จัดการ subscribe/unsubscribe ให้อัตโนมัติ ไม่ต้องเขียนเอง · toSignal() แปลงให้ใช้ในเทมเพลตได้ง่าย · ผสมกับ toObservable ทำ "เปลี่ยน signal → ยิง API ใหม่" ได้:
typescript
import { toSignal } from '@angular/core/rxjs-interop';
// rxjs-interop = ชุดเครื่องมือเชื่อม RxJS กับ Angular signal
@Component({...})
export class UserListComponent {
private http = inject(HttpClient);
// Observable → Signal
users = toSignal(
this.http.get<User[]>('/api/users'),
{ initialValue: [] as User[] } // ค่าเริ่มต้นระหว่างรอข้อมูล
);
}html
<div>{{ users().length }} users</div>
@for (u of users(); track u.id) { ... }With Error Handling
typescript
users = toSignal(
this.http.get<User[]>('/api/users').pipe(
catchError(() => of([]))
),
{ initialValue: [] as User[] }
);Reactive (Signal → HTTP)
typescript
import { toObservable } from '@angular/core/rxjs-interop';
userId = signal(1);
user = toSignal(
toObservable(this.userId).pipe(
switchMap(id => this.http.get<User>(`/api/users/${id}`))
)
);→ เปลี่ยน userId → ยิงโหลด user คนใหม่ให้อัตโนมัติ
8. resource() — Modern Alternative (Angular 19+)
⏭️ มือใหม่ที่เพิ่งอ่าน section 7 จบ: ถ้ายังไม่มั่นใจ ข้าม section นี้ไปก่อนได้ — ใช้
toSignal()จาก section 7 พอแล้วสำหรับงานส่วนใหญ่ ค่อยกลับมาอ่านresource()ทีหลังเมื่อโปรเจกต์ต้องการ loading/error/reload ครบ ๆ
📝 เลือกใช้อะไรเมื่อไหร่?
- เริ่มต้น / Angular < 19 →
toSignal()(section 7) — เรียบง่าย ทำงานได้ทุกเวอร์ชัน- Angular 19+ ต้องการ loading/error/reload ครบ →
resource()หรือrxResource- Angular 19+ งาน HTTP-heavy ต้องการ cache + status →
httpResource(section 8.5)
resource() (Angular 19+) จัดการ async ให้ครบในตัว — loading/error/value เป็น signal พร้อมใช้ + reload ได้ ไม่ต้องเขียน boilerplate (= โค้ดซ้ำ ๆ ที่ต้องเขียนทุกครั้งแม้ทำงานเดิม) เอง · มี rxResource เวอร์ชันสำหรับ RxJS:
ก่อน resource() ถ้าอยากโหลดข้อมูลพร้อม loading/error คุณต้องเขียนแบบนี้ทุกครั้ง: loading = signal(false), error = signal<string | null>(null), data = signal<User[]>([]) แล้วก็ try { loading.set(true); data.set(await fetch(...)); } catch(e) { error.set(e.message); } finally { loading.set(false); } — นั่นคือ boilerplate ประมาณ 10 บรรทัดทุกครั้งที่อยากโหลดอะไรสักอย่าง resource() ย่อทั้งหมดนั้นลงเหลือแค่ 2 key: request (บอกว่าข้อมูลอะไรเปลี่ยนแล้วจะยิงใหม่ — เหมือนบอก "ถ้า userId เปลี่ยน ให้โหลดใหม่") และ loader (บอกวิธีโหลดข้อมูล — เหมือนบอก "วิธีดึงข้อมูลคือ fetch ไป /api/users/:id") แค่นั้น ส่วน loading/error/value Angular จัดการให้ครบเอง
typescript
import { resource } from '@angular/core';
@Component({...})
export class UserDetailComponent {
userId = input.required<number>();
user = resource({
request: () => ({ id: this.userId() }),
loader: async ({ request }) => {
const res = await fetch(`/api/users/${request.id}`);
return res.json() as Promise<User>;
}
});
}html
@if (user.isLoading()) {
<p>Loading...</p>
} @else if (user.error()) {
<!-- error type เป็น unknown — ต้อง type-guard ก่อนเข้าถึง .message -->
<p>Error: {{ user.error() instanceof Error ? user.error()!.message : 'Unknown error' }}</p>
} @else {
<!-- @let = ประกาศตัวแปรใน template (Angular 18+) เพื่อหลีกเลี่ยงการเรียก signal ซ้ำ -->
@let u = user.value()!;
<h1>{{ u.name }}</h1>
}
<button (click)="user.reload()">Refresh</button>rxResource — for RxJS
typescript
import { rxResource } from '@angular/core/rxjs-interop';
user = rxResource({
request: () => ({ id: this.userId() }),
loader: ({ request }) =>
this.http.get<User>(`/api/users/${request.id}`)
});→ มีให้ในตัว: loading + error + reload — ไม่ต้องเขียน boilerplate เอง
8.5. httpResource (Angular 19+) — Signal-native HTTP
⚠️ Experimental API —
httpResourceยังถือว่า experimental ใน Angular 19/20 (อาจมี breaking change ระหว่าง major version) · ใช้ในโปรเจกต์ production ตอนนี้ควรอ่าน release notes ของเวอร์ชันที่ใช้ก่อน · last reviewed: 2026-06
httpResource คือ resource ที่ผูกกับ HttpClient โดยตรง — เขียน URL เป็นฟังก์ชันที่อ้างอิง signal แล้วมัน "ยิงใหม่อัตโนมัติเมื่อ signal เปลี่ยน" + ได้ loading/error/value/status เป็น signal ครบ:
typescript
import { httpResource } from '@angular/common/http';
@Component({...})
export class UserDetailComponent {
userId = input.required<number>();
name = signal(''); // ⚠️ signal ที่ httpResource POST ด้านล่างอ้างถึง — ต้องประกาศก่อนใช้
// URL แบบ reactive — ยิงใหม่อัตโนมัติเมื่อ userId เปลี่ยน
user = httpResource<User>(() => `/api/users/${this.userId()}`);
// พร้อม config (POST, headers, params)
// ⚠️ ระวัง: POST resource จะ "ยิงใหม่" ทุกครั้งที่ signal ที่อ้างถึง (name/userId) เปลี่ยน
// → อาจ POST ซ้ำโดยไม่ตั้งใจ ใช้กับการเขียนข้อมูลควรระวัง (อ่านดีกว่าเขียน)
// ตัวอย่างนี้ผูกกับ this.name() ตรง ๆ เพื่อความสั้น — งานจริงควรผูกกับ signal
// ที่เปลี่ยนเฉพาะตอนกด submit (เช่น submitTrigger) ไม่ใช่ signal ที่พิมพ์ทุกตัวอักษร (เช่น name)
// ไม่งั้นจะ POST รัวทุกครั้งที่ผู้ใช้พิมพ์
saved = httpResource<User>(() => ({
url: '/api/users',
method: 'POST',
body: { name: this.name() },
headers: { 'X-Custom': 'user-detail-form' },
params: { id: this.userId() }
}));
}html
@if (user.isLoading()) {
<p>Loading...</p>
} @else if (user.error()) {
<!-- error type เป็น unknown — ต้อง type-guard ก่อนเข้าถึง .message -->
<p>Error: {{ user.error() instanceof Error ? user.error()!.message : 'Unknown error' }}</p>
} @else {
@let u = user.value()!;
<h1>{{ u.name }}</h1>
<button (click)="user.reload()">Refresh</button>
}
<!-- สถานะ: 'idle' (ว่าง) | 'loading' | 'resolved' (เสร็จ) | 'error' | 'reloading' (กำลังโหลดซ้ำ) -->
<p>Status: {{ user.status() }}</p>Conditional Fetch
typescript
user = httpResource<User>(() => {
const id = this.userId();
if (!id) return undefined; // คืน undefined → ไม่ต้องยิง request
return `/api/users/${id}`;
});Local Update — Optimistic Pattern (อัปเดต UI ก่อน รอผล API ทีหลัง)
📝 Optimistic update (อัปเดตแบบมองโลกในแง่ดี) = อัปเดต UI ก่อน สมมุติว่า API จะสำเร็จ — ถ้าพังค่อย rollback (= ย้อนกลับ คืนค่าเดิม)
⚠️
httpResourceคืนvalueที่อ่านอย่างเดียว — ไม่มี.update()· ทำ optimistic ต้องเก็บค่าใน signal แยกที่ผู้ใช้แก้ได้:
typescript
// เก็บใน WritableSignal แยก เพื่อแก้ค่าเองได้ (ไม่ใช้ httpResource ตรง ๆ)
localPost = signal<Post | null>(null);
likePost(postId: number) {
// 1. optimistic — อัปเดต UI ก่อน (สมมุติว่า API จะสำเร็จ)
this.localPost.update(p => p ? { ...p, likes: p.likes + 1 } : p);
// 2. ยิง API จริง
this.http.post(`/api/posts/${postId}/like`, {}).subscribe({
error: () => {
// 3. ถ้า error → rollback (ย้อนค่ากลับ ลด likes ลง 1 หรือดึงค่าจริงจาก server ใหม่)
this.localPost.update(p => p ? { ...p, likes: p.likes - 1 } : p);
}
});
}→ httpResource = แนะนำมากกว่า resource สำหรับงาน HTTP (มี cache + retry hook ในตัว)
9. HTTP Interceptor
Interceptor คือ "ด่านกลาง" ที่ทุก request (คำขอที่ส่งออก) / response (คำตอบที่ได้กลับ) วิ่งผ่าน — ใช้ทำสิ่งที่ต้องทำกับ "ทุกคำขอ" ที่เดียว เช่นแนบ token, ดัก error 401 เด้งไป login, retry, โชว์ loading · เขียนเป็นฟังก์ชันแล้วลงทะเบียนใน withInterceptors([...]):
แก้ request/response ของทุกคำขอที่เดียว:
ลองนึกภาพว่า interceptor คือ "ด่านตรวจ" ที่ request ต้องวิ่งผ่านทุกด่านก่อนถึง server และวิ่งกลับผ่านทุกด่านอีกครั้งก่อนถึง component:
text
component ยิง HTTP
→ [retryInterceptor] (ห่อนอกสุด — ดูภาพรวมว่าต้องลองใหม่ไหม)
→ [authInterceptor] (ใส่ token)
→ [errorInterceptor] (ดัก error)
→ server
← [errorInterceptor] (เจอ 401 → redirect login)
← [authInterceptor]
← [retryInterceptor] (ถ้า 5xx → ลองใหม่)
component ได้รับผลลำดับใน withInterceptors([retryInterceptor, authInterceptor, errorInterceptor]) กำหนดว่าใครอยู่ด้านนอก — ตัวแรกในอาร์เรย์ห่อรอบนอกสุด
💡 ทำไม
req.clone()แทนการแก้ req ตรง ๆ? — request เหมือนแบบฟอร์มราชการที่ประทับตราแล้ว แก้ตัวอักษรตรง ๆ ไม่ได้ (Angular ทำให้มันเป็น immutable เพื่อกัน interceptor ตัวอื่นงัดแง่ข้อมูลกัน)clone()คือ "ถ่ายสำเนา" แล้วเพิ่มลายเซ็น (Authorization header) ลงบนสำเนานั้น ส่วนต้นฉบับยังคงเดิม
typescript
// auth.interceptor.ts — แนบ token ให้ทุก request
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
// AuthService คือ service ที่เราสร้างเองเพื่อเก็บ token (ดูตัวอย่างใน [บทที่ 3](03-services-and-di.md))
// ไม่ใช่ built-in Angular
const auth = inject(AuthService);
const token = auth.token();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next(req);
};typescript
// app.config.ts
provideHttpClient(
withInterceptors([authInterceptor, errorInterceptor, loggingInterceptor])
)Error Interceptor
typescript
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
// NotificationService คือ service ที่ต้องเขียนเองหรือใช้ library UI — ไม่ใช่ built-in Angular
const notify = inject(NotificationService);
return next(req).pipe(
catchError(err => {
if (err.status === 401) {
router.navigate(['/login']); // ยังไม่ login → เด้งไปหน้า login
} else if (err.status === 403) {
notify.error('Access denied'); // ไม่มีสิทธิ์
} else if (err.status >= 500) {
notify.error('Server error'); // เซิร์ฟเวอร์มีปัญหา
}
return throwError(() => err);
})
);
};Retry Interceptor
typescript
import { retry, timer, throwError } from 'rxjs';
// throwError(() => err) = สร้าง Observable ที่ error ทันที ต่างจาก throw err ปกติ
// ที่ต้องใช้ใน RxJS เพราะต้องคืน Observable ออกมาจาก pipe เสมอ
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
retry({
count: 3,
delay: (err, attempt) => {
if (err.status < 500) {
return throwError(() => err); // ไม่ลองใหม่ถ้าไม่ใช่ error 5xx
}
// ✅ Exponential backoff + jitter (ดูคำอธิบายเต็มด้านล่างโค้ดนี้)
const baseMs = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
const jitterMs = Math.random() * 1000; // randomize ±1s
return timer(baseMs + jitterMs);
}
})
);
};สรุปไทยล้วนก่อนลุย: ถ้า server พัง แล้ว client ทุกเครื่อง retry (ลองใหม่) พร้อมกันที่เวลาเท่ากันเป๊ะ → server จะรับ request พรึ่บเดียวพังซ้ำ ทางแก้: หน่วงเวลานานขึ้นเรื่อย ๆ แบบทวีคูณ (exponential = 2s, 4s, 8s ไม่ใช่ linear = เพิ่มทีละ 1s) + สุ่มเลื่อน ±1s (jitter = สุ่มหน่วง) เพื่อให้แต่ละเครื่องไม่ retry พร้อมกัน
⚠️
timer(1000 * attempt)= linear (1s, 2s, 3s), ไม่ใช่ exponential — exponential ต้องMath.pow(2, attempt)โดย attempt เริ่มที่ 1 จะได้ (2s, 4s, 8s, 16s...) Jitter = randomize delay เล็กน้อยกัน thundering herd (= ฝูงม้า/ควายวิ่งพร้อมกัน — ถ้า 1000 client retry พร้อมกันที่ 2s → server พังซ้ำ) Interceptor order: ลำดับในwithInterceptors([retryInterceptor, errorInterceptor])สำคัญ — ตัวที่อยู่ก่อนใน array จะ wrap รอบนอก ดังนั้นretryInterceptorต้องอยู่ก่อนerrorInterceptorเพื่อให้ retry ทำงานก่อนที่ catchError จะกลืน error ไป
Loading Interceptor
typescript
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loading = inject(LoadingService);
// ⚠️ ขั้นสูง: loading.show() ถูกเรียกก่อน request จริง ถ้า interceptor ก่อนหน้า cancel request
// โดยไม่เรียก next(req) เลย finalize จะไม่รัน → spinner ค้าง
// สำหรับงาน production พิจารณาใช้ defer() แต่ตัวอย่างนี้เพียงพอสำหรับ use case ทั่วไป
loading.show();
return next(req).pipe(
finalize(() => loading.hide()) // finalize = ทำตอนจบ (สำเร็จหรือ error ก็ตาม) → ซ่อน loading
);
};แบบ class (ของเก่า)
📝 class-based interceptor (
HTTP_INTERCEPTORS) ยังใช้งานได้อยู่ ไม่ได้ถูกลบทิ้ง — เพียงแต่ไม่ใช่ default ที่แนะนำอีกต่อไป ควรตรวจสอบสถานะล่าสุดใน release notes ของ Angular เวอร์ชันที่ใช้อีกครั้งก่อนตัดสินใจว่าจะย้ายมาใช้ functional interceptor ทั้งหมดหรือไม่
typescript
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler) {
// ...
return next.handle(req);
}
}
// ประกาศ provide
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }→ Angular ยุคใหม่ = ใช้ interceptor แบบฟังก์ชัน (สะอาดกว่า)
10. Handle HTTP Errors
API พังได้เสมอ (network ล่ม, server error, ไม่มีสิทธิ์) — ดักด้วย catchError แล้วแยกแยะว่าเป็น error ฝั่ง client (network ล่ม, CORS = Cross-Origin Resource Sharing นโยบายความปลอดภัยของ browser ที่อาจบล็อก API ถ้า domain ไม่ตรงกัน) หรือ server ตอบ error มา · ดูตาราง status code ที่เจอบ่อยด้วย:
typescript
import { HttpErrorResponse } from '@angular/common/http';
this.http.get<User[]>('/api/users').pipe(
catchError((err: HttpErrorResponse) => {
if (err.error instanceof ErrorEvent) {
// error ฝั่ง client (network ล่ม, CORS)
console.error('Client error:', err.error.message);
} else {
// server ตอบ error กลับมา
console.error(`Status: ${err.status}, Body:`, err.error);
}
return throwError(() => new Error('Failed'));
})
);Common Status Codes
text
200 — OK (สำเร็จ)
201 — Created (สร้างสำเร็จ)
204 — No Content (สำเร็จ ไม่มีเนื้อหาตอบ)
400 — Bad Request (ข้อมูลที่ส่งไม่ผ่าน validation)
401 — Unauthorized (ไม่มี/token ไม่ถูกต้อง — ยังไม่ login)
403 — Forbidden (ไม่มีสิทธิ์)
404 — Not Found (ไม่พบ)
409 — Conflict (ชนกัน เช่นข้อมูลซ้ำ)
422 — Unprocessable Entity (format ถูก/parse ได้ แต่ข้อมูลผิด business rule เช่น email ซ้ำ หรือวันเกิดอนาคต — ต่างจาก 400 ที่ format ผิดเลย)
500 — Internal Server Error (เซิร์ฟเวอร์พัง)
503 — Service Unavailable (เซิร์ฟเวอร์ไม่พร้อมให้บริการ)11. File Upload — FormData
อัปโหลดไฟล์ส่งเป็น FormData (FormData = รูปแบบข้อมูลมาตรฐานของ browser สำหรับส่งไฟล์ + ข้อมูลปนกัน เพราะไฟล์เป็น binary ส่งเป็น JSON ตรง ๆ ไม่ได้ ต่างจาก JSON ที่ส่งได้แค่ข้อความ) · ถ้าอยากโชว์ progress bar ใส่ reportProgress: true + observe: 'events' แล้วฟัง event ชนิด UploadProgress:
typescript
@Injectable({ providedIn: 'root' })
export class UploadService {
private http = inject(HttpClient);
uploadFile(file: File) {
const formData = new FormData();
formData.append('file', file);
formData.append('description', 'My file');
return this.http.post<{ url: string }>('/api/upload', formData);
}
// พร้อมรายงานความคืบหน้า (progress)
uploadWithProgress(file: File) {
const formData = new FormData();
formData.append('file', file);
return this.http.post<{ url: string }>('/api/upload', formData, {
reportProgress: true, // รายงาน progress
observe: 'events' // ฟัง event ระหว่างทาง (ไม่ใช่แค่ผลสุดท้าย)
});
}
}typescript
import { HttpEventType, HttpResponse } from '@angular/common/http';
@Component({...})
export class UploadComponent {
progress = signal(0);
upload(file: File) {
this.uploadService.uploadWithProgress(file).subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress) {
// event ระหว่างอัปโหลด → คำนวณ % ความคืบหน้า
// event.total! — ! บอก TypeScript ว่า total จะไม่เป็น null แน่นอนตรงนี้
this.progress.set(Math.round(100 * event.loaded / event.total!));
} else if (event instanceof HttpResponse) {
console.log('Done:', event.body); // อัปโหลดเสร็จ
}
}
});
}
}12. Cancel Request
ยกเลิก request ที่ค้างได้ (ข้อดีของ Observable เหนือ Promise) — สำคัญกับ search ที่พิมพ์เร็ว ๆ จะได้ไม่ยิงซ้อนกัน · วิธีที่แนะนำใน Angular ยุคใหม่: ใช้ switchMap ที่ยกเลิกอันเก่าให้อัตโนมัติ:
typescript
// ✅ วิธีแนะนำ (modern) — ปลอดภัย, ไม่ leak, ยกเลิกอัตโนมัติ
this.search.valueChanges.pipe(
debounceTime(300),
switchMap(q => this.http.get<User[]>('/api/search', { params: new HttpParams().set('q', q) })),
takeUntilDestroyed() // ยกเลิกอัตโนมัติเมื่อ component ถูกทำลาย
).subscribe(users => this.results.set(users));แบบ manual (สำหรับ codebase เก่า — เข้าใจไว้)
ถ้าเจอ code เก่าที่จัดการ subscription เอง จะหน้าตาแบบนี้:
typescript
@Component({...})
export class SearchComponent {
private http = inject(HttpClient);
results = signal<User[]>([]);
private subscription?: Subscription;
search(q: string) {
// ยกเลิกคำค้นก่อนหน้า (manual tracking)
this.subscription?.unsubscribe();
this.subscription = this.http
.get<User[]>('/api/search', { params: new HttpParams().set('q', q) })
.subscribe(users => this.results.set(users));
}
ngOnDestroy() {
this.subscription?.unsubscribe(); // ยกเลิกตอน component ถูกทำลาย
}
}→ ใช้ได้แต่จุกจิก · code ใหม่ใช้ switchMap + takeUntilDestroyed() ดีกว่า
Cancel ด้วย AbortController (สำหรับ fetch ดิบ — นอก HttpClient)
ถ้าใช้ fetch() ตรง ๆ (fetch ดิบ = ใช้ browser Fetch API โดยตรงโดยไม่ผ่าน Angular HttpClient — ไม่ผ่าน interceptor ใด ๆ) ใช้ AbortController (= ตัวควบคุมยกเลิก request) ยกเลิกได้:
typescript
const controller = new AbortController();
fetch('/api/search', { signal: controller.signal })
.then(res => res.json())
.catch(err => {
if (err.name === 'AbortError') return; // ถูกยกเลิก — ไม่ต้องทำอะไร
console.error(err);
});
// ที่อื่น — เรียก controller.abort() เพื่อยกเลิก request
controller.abort();13. Cleanup with takeUntilDestroyed
ถ้า subscribe Observable เองแล้วไม่ยกเลิกตอน component ถูกทำลาย จะเกิด memory leak (= subscription ที่ยังทำงานอยู่หลัง component ถูกลบออกไปแล้ว กิน memory โดยเปล่าประโยชน์ ถ้าเกิดซ้ำ ๆ แอปจะช้าลงหรือค้างในที่สุด) · takeUntilDestroyed() จัดการ unsubscribe ให้อัตโนมัติเมื่อ component ตาย (หรือใช้ async pipe / toSignal ที่จัดการให้อยู่แล้ว):
typescript
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({...})
export class MyComponent {
constructor() {
// ยกเลิก subscribe อัตโนมัติเมื่อ component ถูกทำลาย
this.http.get<User[]>('/api/users').pipe(
takeUntilDestroyed()
).subscribe(users => /* เขียนโค้ดที่ต้องทำกับ users ตรงนี้ */);
}
// นอก constructor — ต้องส่ง destroyRef เข้าไป
private destroyRef = inject(DestroyRef);
loadLater() {
this.http.get<User[]>('/api/users').pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(/* ... */);
}
}→ วิธีที่สะอาดสุดในการกัน memory leak บน Angular 16+
14. Async Pipe — Subscribe in Template
async pipe คือวิธีง่ายสุดในการแสดง Observable — เขียน observable$ | async ในเทมเพลต แล้ว Angular subscribe/unsubscribe ให้เอง (ไม่ leak) · (Angular ยุค signal นิยม toSignal แทน แต่ async pipe ยังใช้ได้ดี)
📝 pipe ใน Angular (สัญลักษณ์
|) คือตัวแปลงค่าในเทมเพลต —asyncpipe พิเศษตรงที่ subscribe Observable ให้อัตโนมัติ (pipe อื่น เช่นdate,currencyใช้แปลงรูปแบบการแสดงผล)
typescript
@Component({
template: `
@if (users$ | async; as users) {
<div>Total: {{ users.length }}</div>
@for (u of users; track u.id) {
<div>{{ u.name }}</div>
}
} @else {
<p>Loading...</p>
}
`
})
export class UserListComponent {
private http = inject(HttpClient);
users$ = this.http.get<User[]>('/api/users');
}→ async pipe จัดการ subscribe + unsubscribe ให้อัตโนมัติ
15. Common Patterns
4 แพตเทิร์น HTTP ที่เจอเกือบทุกแอป:
- CRUD service — รวม endpoint ของ resource หนึ่งไว้ที่เดียว
- pagination — แบ่งหน้าข้อมูล
- caching ด้วย
shareReplay— เรียก API ครั้งเดียวแชร์ทุกคน - polling — ถามสถานะซ้ำทุก N วินาที
CRUD Service
typescript
@Injectable({ providedIn: 'root' })
export class UserApiService {
private http = inject(HttpClient);
private baseUrl = '/api/users';
list(params?: Record<string, string | number>) {
return this.http.get<User[]>(this.baseUrl, { params });
}
get(id: number) {
return this.http.get<User>(`${this.baseUrl}/${id}`);
}
create(user: Partial<User>) {
return this.http.post<User>(this.baseUrl, user);
}
update(id: number, user: Partial<User>) {
return this.http.put<User>(`${this.baseUrl}/${id}`, user);
}
patch(id: number, partial: Partial<User>) {
return this.http.patch<User>(`${this.baseUrl}/${id}`, partial);
}
delete(id: number) {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}Pagination
typescript
list(page: number, size: number, search?: string) {
// Angular 14+ — .set() รับ number ได้เลย ไม่ต้องแปลงเป็น String()
let params = new HttpParams()
.set('page', page)
.set('size', size);
if (search) params = params.set('q', search);
return this.http.get<PageResult<User>>(this.baseUrl, { params });
}
// PageResult<T> — <T> คือ placeholder สำหรับ type ของข้อมูล
// เมื่อใช้ PageResult<User> จะหมายความว่า T = User และ content มีชนิดเป็น User[]
interface PageResult<T> {
content: T[];
page: number;
size: number;
total: number;
}Caching Pattern — เรียก API ครั้งเดียวแล้วแชร์ผลให้ทุก subscriber
typescript
import { shareReplay } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ConfigService {
private http = inject(HttpClient);
config$ = this.http.get<AppConfig>('/api/config').pipe(
shareReplay({ bufferSize: 1, refCount: true })
// ✅ refCount: true = เมื่อไม่มีคน subscribe (สมัครรับ) แล้ว → unsubscribe ต้นสาย
// (กัน leak = หลงเหลือ subscription ค้าง กิน memory)
// ❌ shareReplay(1) ตรง ๆ ไม่มี refCount → source ยัง subscribe ค้างแม้ไม่มีใครฟัง
// ⚠️ ข้อแลกเปลี่ยน: ถ้า subscriber คนสุดท้ายออกไป แล้วมีคนใหม่มา subscribe จะยิง HTTP ใหม่
// (ไม่ใช่ singleton ตลอดชีวิตแอป) — ถ้าอยาก load ครั้งเดียวจริง ๆ ใช้ refCount: false
// 📝 config$ เป็น class field เลยถูก "ประกาศ" ทันทีตอน service ถูกสร้าง (constructor ทำงาน)
// แต่ตัว HTTP GET จริง ๆ ยังไม่ยิงจนกว่าจะมีคน subscribe ครั้งแรก — Observable ยัง lazy
// เหมือนที่อธิบายไว้ใน section 4 (แค่ pipe/operator ถูกต่อสายไว้ล่วงหน้าเฉย ๆ)
);
getConfig() {
return this.config$;
}
}สมมุติมี 3 component subscribe config$ พร้อมกัน — shareReplay จะยิง HTTP แค่ครั้งเดียวและส่งผลให้ทั้ง 3 ได้เหมือนกัน แต่พฤติกรรมต่างกันตาม refCount:
refCount: true— เมื่อทั้ง 3 component ถูกทำลาย (ปิดทุกหน้าที่ใช้ config) การเชื่อมต่อกับ server จะถูกตัด ถ้าต่อมามี component ใหม่มา subscribe จะยิง HTTP ใหม่อีก 1 ครั้ง ✅ เหมาะกับข้อมูลที่ OK จะโหลดใหม่ถ้าผู้ใช้กลับมา เช่น config ที่อาจเปลี่ยนระหว่าง sessionrefCount: false— HTTP ยิงครั้งเดียวตลอดชีวิตแอป แม้ไม่มีใคร subscribe เลยก็ยังค้างผลไว้ ✅ เหมาะกับข้อมูลที่ load ครั้งเดียวพอ เช่น list ประเทศหรือค่าคงที่จาก backend ⚠️ แต่ระวัง memory ถ้า response ใหญ่
→ ยิง HTTP ครั้งเดียว — ทุกคนที่ subscribe ได้ผลเดียวกัน
Polling
typescript
import { timer } from 'rxjs';
import { switchMap, takeWhile } from 'rxjs';
@Component({...})
export class JobStatusComponent {
jobId = input.required<string>();
status$ = timer(0, 5000).pipe( // ถามซ้ำทุก 5 วินาที
switchMap(() => this.http.get<JobStatus>(`/api/jobs/${this.jobId()}`)),
takeWhile(s => s.status === 'RUNNING', true)
// หยุดถามเมื่อไม่ใช่ RUNNING แล้ว
// อาร์กิวเมนต์ที่ 2 (true) = "inclusive" — ให้ส่งค่าสุดท้ายที่ทำให้เงื่อนไขเป็นเท็จออกมาด้วย
// (ไม่งั้น status สุดท้ายที่เป็น COMPLETED/FAILED จะไม่ถูกส่งออกมาเลย ผู้ใช้จะไม่เห็นผลจบ)
);
}html
@if (status$ | async; as status) {
@if (status.status === 'COMPLETED') {
<p>Done!</p>
} @else if (status.status === 'FAILED') {
<p>Failed</p>
} @else {
<p>Running... {{ status.progress }}%</p>
}
}16. ⚠️ Common Pitfalls
ข้อผิดพลาดเรื่อง HTTP/RxJS ที่เจอบ่อย — อันตรายสุดคือ subscribe ซ้อน subscribe (nested) และลืม unsubscribe (memory leak) · ตารางนี้รวมไว้พร้อมวิธีแก้:
| ❌ ที่มักทำผิด | ✅ ที่ถูก |
|---|---|
ลืม provideHttpClient() | เพิ่มใน providers ของ app.config |
| เรียก HTTP ใน template | ทำใน service + subscribe ใน component |
| subscribe เองแล้วไม่ unsubscribe | ใช้ async pipe หรือ takeUntilDestroyed |
| subscribe ซ้อน subscribe (nested) | ใช้ switchMap / mergeMap |
| ไม่จัดการ error | catchError + ค่าสำรอง |
| subscribe ใน constructor เพื่อเข้า ViewChild/DOM (ยังไม่พร้อม) | ใช้ ngOnInit หรือ ngAfterViewInit สำหรับ DOM |
| ใส่ HttpClient ใน component โดยตรง | inject ไว้ใน service |
| ผสม Promise + Observable มั่ว | เลือกอย่างใดอย่างหนึ่งให้สม่ำเสมอ |
โยน error ใน catchError โดยไม่ใช้ throwError(() => err) | ใช้ให้ถูกวิธี |
| สร้าง HttpParams() แล้วไม่รับค่าที่คืนกลับ | จำว่ามัน immutable ต้องต่อ chain: params.set(...).set(...) |
17. Testing HTTP
เทสต์โค้ดที่เรียก API ได้โดยไม่ยิง network จริง — ใช้ provideHttpClientTesting() + HttpTestingController จำลอง response ที่อยากได้ แล้วเช็คว่ายิงถูก method/URL ไหม:
📝
describe,it,beforeEach,afterEachคือฟังก์ชันของ Jasmine/Jest testing framework ·TestBedคือ Angular test environment helper ที่ช่วยสร้าง component/service ในสภาพแวดล้อมทดสอบ
typescript
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting()
]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // ตรวจว่าไม่มี request ที่ค้างอยู่โดยไม่ได้รับการจัดการ
});
it('should fetch users', () => {
const mockUsers = [{ id: 1, name: 'Anna' }];
service.list().subscribe(users => {
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('should handle error', () => {
service.list().subscribe({
error: (err) => {
expect(err.status).toBe(500);
}
});
httpMock.expectOne('/api/users').flush(null, {
status: 500,
statusText: 'Server Error'
});
});
});18. RxJS Cheat Sheet
สรุป operator ที่ใช้บ่อยรวมไว้ที่เดียวเป็นแผ่นโพย — แบ่งตามหมวด (สร้าง/แปลง/กรอง/รวม/error/side-effect) เปิดดูเร็วเวลาลืม:
text
สร้าง (Create):
of(1, 2, 3) — ส่งค่าทันที (synchronous)
from([1, 2, 3]) — สร้างจาก array
interval(1000) — ทุก 1 วินาที
timer(500, 1000) — หน่วงตอนแรก แล้วทำซ้ำเป็นช่วง
fromEvent(el, 'click') — จาก DOM event
EMPTY — จบทันที (ไม่ส่งค่า)
แปลง (Transform):
map(fn) — แปลงค่า
switchMap(fn) — เปลี่ยนไป observable ใหม่ (ยกเลิกอันเก่า)
mergeMap(fn) — ขนาน (พร้อมกัน)
concatMap(fn) — ตามลำดับ (ทีละอัน)
exhaustMap(fn) — เมินค่าใหม่จนกว่าอันแรกจะเสร็จ
scan((acc, val) => ...) — เหมือน reduce แต่ส่งผลสะสมออกมาเรื่อย ๆ
กรอง (Filter):
filter(fn) — กรองตามเงื่อนไข
distinctUntilChanged() — ข้ามถ้าค่าเหมือนเดิม
take(n) — เอาแค่ n ค่าแรก
takeUntil(notifier$) — รับจนกว่า notifier จะส่งค่า
takeWhile(fn) — รับตราบที่เงื่อนไขจริง
skip(n) — ข้าม n ค่าแรก
debounceTime(ms) — หน่วงเวลา (รอให้นิ่งก่อน)
throttleTime(ms) — จำกัดความถี่
รวม (Combine):
combineLatest([a$, b$]) — ค่าล่าสุดของแต่ละตัว
forkJoin({ a: a$, b: b$ }) — รอครบทุกอัน + ส่งครั้งเดียว
zip([a$, b$]) — จับคู่ทีละค่า
merge(a$, b$) — รวมหลายสายเป็นสายเดียว
Error:
catchError(err => fallback$) — ดัก error แล้วใช้ค่าสำรอง
retry(n) — ลองใหม่ n ครั้ง
retry({ count, delay }) — ลองใหม่พร้อมหน่วงเวลา
side effect (ทำอะไรข้าง ๆ):
tap(fn) — แอบทำ side effect โดยไม่แปลงค่า
multicast (ส่งให้หลายคน — Observable เดียวส่งให้ subscriber หลายคนพร้อมกัน ต่างจาก merge ที่รวมหลาย Observable เป็นสายเดียว):
shareReplay(1) — cache ค่าสุดท้าย + แชร์
share() — แชร์โดยไม่ cache
วงจรชีวิต (Lifecycle):
finalize(fn) — ทำตอนจบ (สำเร็จหรือ error)
takeUntilDestroyed() — ยกเลิกอัตโนมัติเมื่อ component ตาย (Angular 16+)19. ตัวอย่างเต็ม — User Management
รวมทุกอย่างในบทเป็นระบบจริง — แยกเป็น 3 ชั้น: api (เรียก HTTP), store (เก็บ state ด้วย signal + จัดการ loading/error), component (แค่แสดงผล) · นี่คือสถาปัตยกรรมที่ใช้ได้จริงในโปรเจกต์:
typescript
// user.api.ts
@Injectable({ providedIn: 'root' })
export class UserApi {
private http = inject(HttpClient);
private base = '/api/users';
list(page = 0, size = 20, q?: string) {
// 📝 Angular 14+ .set() รับ number ได้เลย — ถ้าใช้เวอร์ชันเก่ากว่าต้อง String(page)
let params = new HttpParams()
.set('page', page)
.set('size', size);
if (q) params = params.set('q', q);
return this.http.get<PageResult<User>>(this.base, { params });
}
get(id: number) {
return this.http.get<User>(`${this.base}/${id}`);
}
create(data: Omit<User, 'id'>) {
return this.http.post<User>(this.base, data);
}
update(id: number, data: Partial<User>) {
return this.http.patch<User>(`${this.base}/${id}`, data);
}
delete(id: number) {
return this.http.delete<void>(`${this.base}/${id}`);
}
}typescript
// user.store.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UserStore {
private api = inject(UserApi);
private _users = signal<User[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
readonly users = this._users.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly count = computed(() => this._users().length);
async load() {
this._loading.set(true);
this._error.set(null);
try {
// firstValueFrom = เอาค่าแรกจาก Observable มาเป็น Promise ใช้กับ async/await (import ไว้บนแล้ว)
// 💡 หมายเหตุ: pattern นี้สูญเสีย cancellation ของ RxJS ไป —
// ถ้าต้องการ cancel/dedup แบบ Angular-native ลองดู rxResource (section 8)
// หรือ NgRx Signal Store rxMethod (บท 10)
const res = await firstValueFrom(this.api.list(0, 100));
this._users.set(res.content);
} catch (e: any) {
this._error.set(e.message || 'Failed to load');
} finally {
this._loading.set(false);
}
}
async create(user: Omit<User, 'id'>) {
const created = await firstValueFrom(this.api.create(user));
this._users.update(users => [...users, created]);
}
async remove(id: number) {
await firstValueFrom(this.api.delete(id));
this._users.update(users => users.filter(u => u.id !== id));
}
}typescript
// user-list.component.ts
import { Component, OnInit, inject } from '@angular/core';
// 📝 @if, @for, @empty ใน Angular 17+ เป็น built-in control flow — ไม่ต้อง import เพิ่ม
@Component({
selector: 'app-user-list',
standalone: true,
template: `
<div class="users">
<h1>Users ({{ store.count() }})</h1>
@if (store.loading()) {
<p>Loading...</p>
} @else if (store.error()) {
<p class="error">{{ store.error() }}</p>
<button (click)="store.load()">Retry</button>
} @else {
<ul>
@for (u of store.users(); track u.id) {
<li>
{{ u.name }} ({{ u.email }})
<button (click)="remove(u.id)">×</button>
</li>
} @empty {
<p>No users</p>
}
</ul>
}
</div>
`
})
// implements OnInit = แจ้ง TypeScript ให้ช่วย type-check signature ของ lifecycle hook
export class UserListComponent implements OnInit {
store = inject(UserStore);
ngOnInit() {
this.store.load();
}
async remove(id: number) {
if (confirm('ยืนยันการลบ?')) {
await this.store.remove(id);
}
}
}typescript
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, errorInterceptor])
),
provideRouter(routes)
]
};20. Checkpoint
ลองทำ 5 ข้อนี้ไล่จาก CRUD พื้นฐานไปจนถึง interceptor + file upload — เน้น debounced search (ข้อ 6.3) เพราะเป็นแพตเทิร์น RxJS ที่ใช้จริงบ่อยสุด:
🛠️ Checkpoint 6.1 — Basic CRUD
สร้าง UserApi service:
- list, get, create, update, delete
- ใช้ใน component (subscribe)
- Test ใน browser
🛠️ Checkpoint 6.2 — toSignal Integration
- Same UserApi
- Component ใช้ toSignal แทน subscribe
- Add loading + error display
🛠️ Checkpoint 6.3 — Debounced Search
- Search input + valueChanges
- debounceTime(300)
- switchMap to HTTP
- Display results
🛠️ Checkpoint 6.4 — Interceptor
- Auth interceptor (add Bearer token)
- Error interceptor (401 → redirect login)
- Loading interceptor (show spinner)
🛠️ Checkpoint 6.5 — File Upload
- Upload file with FormData
- Progress reporting
- Display percentage
21. สรุปบท
ทบทวนแกนของบท — HttpClient คืนเป็น Observable (lazy, ยกเลิกได้) ที่แปรรูปด้วย operator แล้วมักแปลงเป็น signal ด้วย toSignal/resource เพื่อใช้ใน UI · จำ switchMap (search), catchError (error), interceptor (งานกับทุก request) ได้ก็ครอบคลุมงานจริงส่วนใหญ่:
✅ provideHttpClient() ใน app.config + inject HttpClient ใน service · เพิ่ม withFetch() และ withInterceptors([...]) เป็น default ยุคใหม่ ✅ HTTP methods: get<T> (อ่าน), post<T> (สร้าง), put<T>/patch<T> (แก้), delete (ลบ) ✅ Observable = lazy (ขี้เกียจ — เริ่มเมื่อ subscribe), ยกเลิกได้, ส่งหลายค่า ⇄ Promise = eager (เริ่มทันที), ค่าเดียว ✅ RxJS operator ที่ใช้บ่อย: map (แปลง), filter (กรอง), tap (side effect), catchError (ดัก error), switchMap (เปลี่ยนสตรีม) ✅ switchMap สำหรับ search/navigation (ยกเลิกอันก่อนหน้า) ✅ forkJoin ยิงพร้อมกัน + รอครบทุกอัน ✅ toSignal แปลง Observable → Signal (Angular ยุคใหม่) ✅ resource() / httpResource (Angular 19+) จัดการ async พร้อม loading/error ในตัว ✅ Functional interceptor (Angular 15+) — HttpInterceptorFn + withInterceptors([...]) ✅ takeUntilDestroyed() ยกเลิก subscription อัตโนมัติเมื่อ component ตาย ✅ async pipe subscribe + unsubscribe ให้ใน template ✅ Test HTTP: provideHttpClientTesting() + HttpTestingController (จำลอง response โดยไม่ยิง network จริง)
← บทที่ 5 | บทที่ 7 → Change Detection
🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12