Skip to content

บทที่ 6 — HTTP + RxJS

← บทที่ 5 | สารบัญ | บทที่ 7 →

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

  • ใช้ 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=ts

4. 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 & dogs

typescript
// ✅ 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 < 19toSignal() (section 7) — เรียบง่าย ทำงานได้ทุกเวอร์ชัน
  • Angular 19+ ต้องการ loading/error/reload ครบresource() หรือ rxResource
  • Angular 19+ งาน HTTP-heavy ต้องการ cache + statushttpResource (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 APIhttpResource ยังถือว่า 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 (สัญลักษณ์ |) คือตัวแปลงค่าในเทมเพลต — async pipe พิเศษตรงที่ 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 ที่อาจเปลี่ยนระหว่าง session
  • refCount: falseHTTP ยิงครั้งเดียวตลอดชีวิตแอป แม้ไม่มีใคร 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
ไม่จัดการ errorcatchError + ค่าสำรอง
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