ข้ามไปเนื้อหาหลัก

Category: guide

Fetch API Patterns — Data Fetching, AbortController, Retry

Patterns สำหรับ fetch ข้อมูล: timeout, retry, AbortController, error handling, caching headers, streaming response

· อ่านประมาณ 5 นาที

สารบัญ

Fetch พื้นฐาน

// GET
const res = await fetch('/api/data');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

// POST
const res = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
});

Error Handling ที่ถูกต้อง

// ❌ fetch ไม่ throw สำหรับ 4xx/5xx — ต้อง check res.ok เอง
const res = await fetch('/api/data');
const data = await res.json(); // อาจเป็น error response โดยไม่รู้

// ✓ check status ก่อนเสมอ
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(url, init);
  if (!res.ok) {
    const error = await res.text().catch(() => `HTTP ${res.status}`);
    throw new Error(`Fetch failed ${res.status}: ${error}`);
  }
  return res.json() as Promise<T>;
}

interface User { id: number; name: string }
const user = await fetchJSON<User>('/api/users/1');

AbortController — Cancel Request

// Cancel เมื่อ component unmount
const controller = new AbortController();

fetch('/api/data', { signal: controller.signal })
  .then((res) => res.json())
  .then((data) => console.log(data))
  .catch((err) => {
    if (err.name === 'AbortError') return; // ถูก cancel — ignore
    throw err;
  });

// Cancel
controller.abort();

// Timeout pattern
function fetchWithTimeout(url: string, ms: number, init?: RequestInit) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);

  return fetch(url, { ...init, signal: controller.signal })
    .finally(() => clearTimeout(timer));
}

try {
  const res = await fetchWithTimeout('/api/slow', 5000);
  const data = await res.json();
} catch (err) {
  if ((err as Error).name === 'AbortError') {
    console.error('Request timed out');
  }
}

Retry Pattern

async function fetchWithRetry<T>(
  url: string,
  options: RequestInit & {
    retries?: number;
    retryDelay?: number;
    retryOn?: number[];
  } = {},
): Promise<T> {
  const {
    retries = 3,
    retryDelay = 1000,
    retryOn = [429, 500, 502, 503, 504],
    ...fetchOptions
  } = options;

  let lastError: Error;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(url, fetchOptions);

      if (!res.ok && retryOn.includes(res.status) && attempt < retries) {
        // Exponential backoff
        const delay = retryDelay * 2 ** attempt;
        await new Promise((r) => setTimeout(r, delay));
        continue;
      }

      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json() as Promise<T>;
    } catch (err) {
      lastError = err as Error;
      if (attempt < retries && (err as Error).name !== 'AbortError') {
        await new Promise((r) => setTimeout(r, retryDelay * 2 ** attempt));
        continue;
      }
      throw err;
    }
  }
  throw lastError!;
}

const data = await fetchWithRetry<User>('/api/users/1', {
  retries: 3,
  retryDelay: 500,
  retryOn: [429, 500, 503],
});

Caching Headers

// No cache — ได้ข้อมูลใหม่เสมอ
fetch('/api/data', { cache: 'no-store' })

// Use cache, revalidate in background (stale-while-revalidate)
fetch('/api/data', { cache: 'no-cache' })  // always check server

// ใช้ cache ถ้ามี (default)
fetch('/api/data', { cache: 'default' })

// Force cache, ไม่ไปถามเซิร์ฟเวอร์เลย
fetch('/api/data', { cache: 'force-cache' })

// Request headers สำหรับ conditional fetch
const lastModified = localStorage.getItem('data-modified');
const res = await fetch('/api/data', {
  headers: lastModified ? { 'If-Modified-Since': lastModified } : {},
});

if (res.status === 304) {
  // ไม่มีอะไรใหม่ ใช้ cached data
} else {
  const data = await res.json();
  localStorage.setItem('data-modified', res.headers.get('Last-Modified') ?? '');
}

Streaming Response

// อ่าน ReadableStream ทีละ chunk
async function streamResponse(url: string) {
  const res = await fetch(url);
  if (!res.body) throw new Error('No body');

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let result = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const chunk = decoder.decode(value, { stream: true });
    result += chunk;
    console.log('Chunk:', chunk); // process แต่ละ chunk ทันที
  }

  return result;
}

// Server-Sent Events (SSE) / AI streaming
async function* streamSSE(url: string) {
  const res = await fetch(url, {
    headers: { Accept: 'text/event-stream' },
  });

  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) return;
    buffer += decoder.decode(value, { stream: true });

    const lines = buffer.split('\n');
    buffer = lines.pop() ?? '';

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        yield JSON.parse(line.slice(6));
      }
    }
  }
}

for await (const event of streamSSE('/api/stream')) {
  console.log(event);
}

FormData Upload

// Upload file
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('description', 'My file');

const res = await fetch('/api/upload', {
  method: 'POST',
  body: formData,  // ไม่ต้องตั้ง Content-Type — browser จะ set boundary เอง
});

// Progress tracking ต้องใช้ XMLHttpRequest (fetch ไม่มี upload progress)

Parallel Fetching

// ✓ รันพร้อมกัน — เร็วกว่า await แต่ละตัว
const [users, posts, comments] = await Promise.all([
  fetchJSON<User[]>('/api/users'),
  fetchJSON<Post[]>('/api/posts'),
  fetchJSON<Comment[]>('/api/comments'),
]);

// ✓ allSettled — ไม่ cancel ถ้าตัวใดตัวหนึ่ง fail
const results = await Promise.allSettled([
  fetchJSON('/api/a'),
  fetchJSON('/api/b'),
]);
const data = results
  .filter((r): r is PromiseFulfilledResult<unknown> => r.status === 'fulfilled')
  .map((r) => r.value);

// ❌ sequential — ช้า (รอทีละตัว)
const users = await fetchJSON('/api/users');
const posts = await fetchJSON('/api/posts');

Simple Client-side Cache

const cache = new Map<string, { data: unknown; ts: number }>();

async function cachedFetch<T>(url: string, ttl = 60_000): Promise<T> {
  const cached = cache.get(url);
  if (cached && Date.now() - cached.ts < ttl) {
    return cached.data as T;
  }

  const data = await fetchJSON<T>(url);
  cache.set(url, { data, ts: Date.now() });
  return data;
}

// ใช้:
const users = await cachedFetch<User[]>('/api/users', 30_000); // cache 30 วินาที

Authentication Headers

function createClient(baseUrl: string, getToken: () => string | null) {
  return {
    async get<T>(path: string): Promise<T> {
      const token = getToken();
      return fetchJSON<T>(`${baseUrl}${path}`, {
        headers: token ? { Authorization: `Bearer ${token}` } : {},
      });
    },
    async post<T>(path: string, body: unknown): Promise<T> {
      const token = getToken();
      return fetchJSON<T>(`${baseUrl}${path}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...(token ? { Authorization: `Bearer ${token}` } : {}),
        },
        body: JSON.stringify(body),
      });
    },
  };
}

const api = createClient('https://api.example.com', () => localStorage.getItem('token'));
const users = await api.get<User[]>('/users');