สัปดาห์ที่ 12

Custom Hooks, Reusability & Error Handling

CLO3
📖 ทฤษฎี

1. Custom Hook คืออะไร — Pattern และ Naming Convention

Custom Hook คือ JavaScript Function ธรรมดาที่มีชื่อขึ้นต้นด้วย use และสามารถเรียกใช้ React Hooks ข้างในได้ เช่น useState, useEffect, useQuery หรือ Custom Hook อื่นๆ React กำหนด Convention นี้ไว้เพื่อให้ Linter ตรวจสอบ Rules of Hooks ได้อัตโนมัติ

แนวคิดหลักคือการ แยก Logic ออกจาก UI — Component ควรรับผิดชอบแค่การ Render ส่วน Logic การดึงข้อมูล การจัดการ State หรือ Side Effect ที่ซับซ้อนให้ย้ายไปไว้ใน Custom Hook ทำให้ Component อ่านง่าย ทดสอบง่าย และนำ Logic กลับมาใช้ซ้ำในหลาย Component ได้

  • Naming Convention — ชื่อต้องขึ้นต้นด้วย use เสมอ เช่น useServices, useBookings, useAuth, useLocalStorage
  • Return Value — Custom Hook คืนค่าอะไรก็ได้ที่ Component ต้องการ อาจเป็น Object, Array, หรือ Primitive Value เช่น { data, isLoading, error }
  • Composability — Custom Hook สามารถเรียก Custom Hook อื่นได้ ทำให้ Logic ซับซ้อนแตกออกเป็น Hook เล็กๆ ที่ Compose กันได้
  โครงสร้าง Custom Hook ทั่วไป
  ─────────────────────────────────────────────────
  Component (UI)          Custom Hook (Logic)
  ──────────────────      ─────────────────────────
  function ServicesPage   function useServices() {
  {                         return useQuery({
    const {                   queryKey: ['services'],
      data,                   queryFn: fetchServices,
      isLoading,            });
      error                 }
    } = useServices();
                          function useService(id) {
    if (isLoading) ...      return useQuery({
    if (error)   ...          queryKey: ['services', id],
    return (                  queryFn: () => fetch(id),
      <ServiceCard />       enabled: Boolean(id),
    );                      });
  }                         }
  ─────────────────────────────────────────────────
เคล็ดลับ — เมื่อไหรควรสร้าง Custom Hook

เมื่อคุณพบว่าเขียน Logic เดิมซ้ำในหลาย Component หรือเมื่อ Component มีโค้ดมากเกินไปจน อ่านยาก ให้ย้าย Logic ที่เกี่ยวกับ State หรือ Side Effect ออกมาเป็น Custom Hook กฎง่ายๆ: ถ้า Component มีมากกว่า 3 hooks อยู่ด้านบน มักหมายความว่าพร้อมจะ Extract ออก

2. DRY Principle ในบริบท React

DRY (Don't Repeat Yourself) คือหลักการพัฒนาซอฟต์แวร์ที่บอกว่า "ข้อมูลหรือ Logic ทุกชิ้นควรมีแหล่งที่มาเดียวที่ชัดเจนในระบบ" การเขียนโค้ดซ้ำทำให้เวลาต้องแก้ Bug หรือเปลี่ยนพฤติกรรม ต้องไปแก้หลายที่ ซึ่งเพิ่มโอกาสลืมแก้ที่ใดที่หนึ่งและเกิด Inconsistency

ใน React DRY มีสามระดับหลัก:

  • Component Level — แยก UI ที่เหมือนกันออกเป็น Component เช่น ServiceCard, BookingRow, LoadingSpinner
  • Hook Level — แยก Logic ที่เหมือนกันออกเป็น Custom Hook เช่น useServices ที่ใช้ได้ทั้งใน ServicesPage และ HomePage
  • Utility Level — แยก Helper Function ที่ไม่มี State ออกมา เช่น formatPrice(), formatDate()

เมื่อนำ DRY ไปใช้กับ Data Fetching ใน React Query ผลลัพธ์คือ Custom Hook ที่ห่อ useQuery หรือ useMutation ไว้ภายใน ทำให้ query key และ queryFn อยู่ในที่เดียว ไม่กระจายอยู่ทั่ว Codebase

หมายเหตุ — DRY กับ "Three Strikes Rule"

หลักการ "Three Strikes" บอกว่า ครั้งแรกที่เขียนโค้ด ให้เขียนได้เลย ครั้งที่สองที่เจอโค้ดคล้ายกัน ให้สังเกตว่าซ้ำ แต่ยังไม่ต้อง Extract ครั้งที่สามให้ Refactor ทันที หลักนี้ช่วยป้องกันการ Abstract ก่อนเวลา (Over-abstraction) ซึ่งทำให้โค้ดอ่านยากขึ้นโดยไม่มีประโยชน์

3. Error Boundary ใน React

Error Boundary คือ React Component พิเศษที่ "จับ" JavaScript Error ที่เกิดขึ้นในระหว่าง Render ของ Component ลูก (Child Tree) และแสดง UI สำรองแทน แทนที่แอปจะ Crash ทั้งหน้าเมื่อ Component ใดมี Error

Error Boundary ทำงานกับ Error ที่เกิดใน:

  • การ Render ของ Component
  • Lifecycle Methods
  • Constructor ของ Component ลูก

Error Boundary ไม่จับ Error ที่เกิดใน:

  • Event Handlers — ต้องใช้ try/catch เอง
  • Asynchronous Code — เช่น setTimeout หรือ Promise
  • Server-Side Rendering
  • Error ใน Error Boundary เอง

ใน React 16+ เราสร้าง Error Boundary เป็น Class Component ที่ implement static getDerivedStateFromError() และ componentDidCatch() แต่ Library เช่น react-error-boundary ช่วยให้ใช้ได้สะดวกขึ้นมากโดยไม่ต้องเขียน Class Component

  Error Boundary — ห่อ Component ที่อาจ Error
  ─────────────────────────────────────────────────
  <ErrorBoundary fallback={<ErrorPage />}>
    <Router />          ← ถ้า Error ที่นี่
  </ErrorBoundary>      → แสดง <ErrorPage /> แทน

  วางตำแหน่ง Error Boundary ให้เหมาะสม:
  ─────────────────────────────────────────────────
  App Level    → จับ Error ทั้งแอป (Safety Net)
  Route Level  → แต่ละ Page แสดง Error ของตัวเอง
  Widget Level → ส่วนย่อยๆ Error โดยไม่พัง Page อื่น

4. Global Error Handling Pattern

นอกจาก Error Boundary แล้ว แอปที่ดีควรมี Global Error Handling ในหลายระดับ:

  • React Query Error Handling — ตั้งค่า onError callback ใน QueryClient เพื่อ Log Error หรือแสดง Toast Notification ทุกครั้งที่ Query ล้มเหลว
  • Axios/Fetch Interceptor — สกัดกั้น HTTP Response ก่อนถึง Handler เพื่อจัดการ 401 Unauthorized (redirect ไปหน้า Login) หรือ 500 Server Error (แสดง Toast) โดยอัตโนมัติ
  • window.onerror และ unhandledrejection — จับ Error ที่ไม่มีใคร Handle และส่งไปยัง Error Tracking Service เช่น Sentry

Pattern ที่แนะนำใน React Query v5 คือตั้งค่า defaultOptions ใน QueryClient:

  ตัวอย่าง Global Error Handler ใน React Query
  ─────────────────────────────────────────────────
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: 1,               ← ลองซ้ำ 1 ครั้งก่อน Error
        staleTime: 5 * 60_000,  ← Cache 5 นาที
      },
    },
    queryCache: new QueryCache({
      onError: (error) => {
        toast.error(error.message);   ← แสดง Toast ทุก Query Error
      },
    }),
    mutationCache: new MutationCache({
      onError: (error) => {
        toast.error(error.message);   ← แสดง Toast ทุก Mutation Error
      },
    }),
  });
ข้อควรระวัง — อย่า Swallow Error

Pattern ที่อันตรายที่สุดคือการ Catch Error แล้วไม่ทำอะไรเลย (catch (e) {}) ซึ่งทำให้ Error หายไปและ Debug ยากมาก เสมอ Log Error อย่างน้อยไปที่ Console หรือส่งไปยัง Error Tracking Service เพื่อให้รู้ว่าเกิดอะไรขึ้นใน Production

5. Loading States และ Skeleton UI

Loading State คือสถานะที่แอปกำลังรอข้อมูลจาก API การจัดการ Loading State ที่ดีทำให้ผู้ใช้รู้ว่าแอปกำลังทำงานอยู่และไม่รู้สึกว่าแอปค้าง มีหลายวิธีในการแสดง Loading State:

  • Spinner/Loading Indicator — วงกลมหมุน เหมาะสำหรับ Action ขนาดเล็ก เช่น กดปุ่ม Submit
  • Skeleton UI — แสดง Placeholder ที่มีรูปร่างเหมือน Content จริง ทำให้ผู้ใช้เห็น Layout ก่อนที่ข้อมูลจะมา ลด Layout Shift และรู้สึกว่าแอปเร็วขึ้น
  • Optimistic Update — แสดงผลลัพธ์ที่คาดว่าจะได้ทันที ก่อน API ตอบกลับ เช่น กด Like แล้วนับเพิ่มทันที ถ้า API ล้มเหลวค่อย Rollback

Shadcn/ui มี Skeleton Component พร้อมใช้ ใช้แทนข้อความ "กำลังโหลด..." โดยแสดง Gray Block ที่มี Animation ให้ผู้ใช้รู้ว่ามีอะไรกำลังโหลดอยู่ สร้าง Skeleton Component ให้มีรูปร่างเหมือน Component จริงเพื่อผลลัพธ์ที่ดีที่สุด

  Skeleton vs Spinner — เลือกใช้เมื่อไหร่
  ─────────────────────────────────────────────────
  Skeleton  → โหลดรายการ / Card / Page Content
             ผู้ใช้เห็น Layout ก่อน ลด Layout Shift

  Spinner   → Submit Form / กดปุ่ม Action
             แสดงว่ากำลัง Process คำสั่งของผู้ใช้

  ตัวอย่าง Skeleton Pattern:
  ─────────────────────────────────────────────────
  isLoading  → แสดง <ServiceCardSkeleton /> x 3
  error      → แสดง Error Message + Retry Button
  data       → แสดง <ServiceCard /> จริง
เคล็ดลับ — Skeleton Animation

Shadcn Skeleton Component ใช้ CSS Animation pulse ซึ่งทำให้ Opacity กระพริบอย่างสม่ำเสมอ ซึ่งบอกผู้ใช้ว่ากำลังโหลดโดยไม่รบกวนสายตา ใช้ Skeleton หลายอันซ้อนกันให้มีขนาดตรงกับข้อความหรือรูปภาพจริง เช่น h-6 w-3/4 สำหรับ Title และ h-4 w-full สำหรับ Body Text

💻 โค้ดตัวอย่าง
src/hooks/useServices.js — Custom Hook สำหรับ Services JavaScript
// snippet 1: Custom Hook — useServices
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

const API_URL = import.meta.env.VITE_API_URL;

export function useServices() {
  return useQuery({
    queryKey: ['services'],
    queryFn: async () => {
      const res = await fetch(`${API_URL}/api/services`);
      if (!res.ok) throw new Error('โหลดบริการล้มเหลว');
      const json = await res.json();
      return json.data;
    },
  });
}

export function useService(id) {
  return useQuery({
    queryKey: ['services', id],
    queryFn: async () => {
      const res = await fetch(`${API_URL}/api/services/${id}`);
      if (!res.ok) throw new Error('ไม่พบบริการ');
      const json = await res.json();
      return json.data;
    },
    enabled: Boolean(id),
  });
}
src/hooks/useBookings.js — Custom Hook สำหรับ Bookings JavaScript
// snippet 2: Custom Hook — useBooking
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../contexts/AuthContext';

const API_URL = import.meta.env.VITE_API_URL;

export function useBookings() {
  const { user } = useAuth();
  return useQuery({
    queryKey: ['bookings', user?.id],
    queryFn: async () => {
      const res = await fetch(`${API_URL}/api/bookings/my`, {
        headers: { Authorization: `Bearer ${user?.access_token}` },
      });
      if (!res.ok) throw new Error('โหลดประวัติล้มเหลว');
      return (await res.json()).data;
    },
    enabled: Boolean(user),
  });
}

export function useCreateBooking() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: async (booking) => {
      const res = await fetch(`${API_URL}/api/bookings`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(booking),
      });
      if (!res.ok) throw new Error((await res.json()).error);
      return res.json();
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['bookings'] }),
  });
}
src/pages/ServicesPage.jsx — Skeleton Loading + Error Handling JSX
// snippet 3: Skeleton Loading + Error Boundary
import { Skeleton } from '@/components/ui/skeleton';

// Skeleton สำหรับ ServiceCard
function ServiceCardSkeleton() {
  return (
    <div className="border rounded-lg p-4 space-y-3">
      <Skeleton className="h-48 w-full rounded" />
      <Skeleton className="h-6 w-3/4" />
      <Skeleton className="h-4 w-full" />
      <Skeleton className="h-10 w-24" />
    </div>
  );
}

// ใช้ใน ServicesPage
function ServicesPage() {
  const { data: services, isLoading, error } = useServices();

  if (isLoading) {
    return (
      <div className="grid grid-cols-3 gap-4">
        {Array(3).fill(0).map((_, i) => <ServiceCardSkeleton key={i} />)}
      </div>
    );
  }

  if (error) {
    return (
      <div className="text-center py-8">
        <p className="text-destructive">{error.message}</p>
        <Button variant="outline" onClick={() => window.location.reload()}>ลองใหม่</Button>
      </div>
    );
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
      {services.map(s => <ServiceCard key={s.id} {...s} />)}
    </div>
  );
}

🧪 ปฏิบัติการ Lab 11 — BookEasy: Refactor เป็น Custom Hooks

ย้าย Logic การดึงข้อมูลทั้งหมดออกจาก Component ไปไว้ใน Custom Hooks และเพิ่ม Skeleton Loading

ต่อยอดจาก Lab 10

ต่อยอดจาก Lab 10 — Refactor โค้ด data fetching ทั้งหมดเป็น custom hooks เพื่อ reusability

1
สร้าง Hooks Files

สร้างโฟลเดอร์ src/hooks/ และสร้างไฟล์ Custom Hook ตาม snippets ด้านบน:

  1. สร้าง src/hooks/useServices.js — ส่งออก useServices() และ useService(id) โดย Wrap useQuery จาก React Query
    • useServices() — ดึงบริการทั้งหมด (queryKey: ['services'])
    • useService(id) — ดึงบริการเดียวตาม id (enabled: Boolean(id))
  2. สร้าง src/hooks/useBookings.js — ส่งออก useBookings() และ useCreateBooking()
    • useBookings() — ดึงการจองของผู้ใช้ปัจจุบัน (ต้องล็อกอินก่อน)
    • useCreateBooking() — Mutation สำหรับสร้างการจองใหม่ พร้อม onSuccess invalidate cache
เกณฑ์การผ่าน

ไฟล์ src/hooks/useServices.js และ src/hooks/useBookings.js ถูกสร้างขึ้นพร้อม export ฟังก์ชันครบถ้วน รัน npm run dev ไม่มี error

คำแนะนำ

ตรวจสอบว่า import useQueryClient จาก @tanstack/react-query ใน useBookings.js เพื่อใช้ queryClient.invalidateQueries() ใน onSuccess Callback ของ useCreateBooking

2
Refactor Pages ให้ใช้ Custom Hooks

แทนที่การเรียก useQuery และ useMutation โดยตรงใน Component ด้วย Custom Hooks ที่สร้างไว้ใน Task 1:

  1. แก้ไข src/pages/ServicesPage.jsx:
    • ลบ import { useQuery } from '@tanstack/react-query'
    • เพิ่ม import { useServices } from '../hooks/useServices'
    • เปลี่ยนจาก const { data } = useQuery({...}) เป็น const { data: services, isLoading, error } = useServices()
  2. แก้ไข src/pages/ServiceDetailPage.jsx:
    • ใช้ useService(id) แทน useQuery โดยตรง
    • ดึง id จาก Route Params ตามเดิม
  3. แก้ไข src/components/BookingForm.jsx หรือ BookingDialog.jsx:
    • ใช้ useCreateBooking() แทน useMutation โดยตรง
    • ลบ Query Key และ QueryFn ออกจาก Component
เกณฑ์การผ่าน

แอปทำงานได้เหมือนเดิมทุกฟังก์ชัน (แสดงบริการ, ดูรายละเอียด, จองได้) โค้ดใน ServicesPage ไม่มี queryKey หรือ queryFn โดยตรงอีกต่อไป

คำแนะนำ

ใช้ git diff ตรวจสอบว่าโค้ดในแต่ละไฟล์สั้นลงและอ่านง่ายขึ้น ถ้า Component ยังมี queryKey อยู่ แสดงว่า Refactor ยังไม่สมบูรณ์

3
เพิ่ม Skeleton Loading

แทนที่ข้อความ "กำลังโหลด..." ด้วย Skeleton UI ที่สวยงามกว่า โดยใช้ Shadcn Skeleton Component:

  1. ติดตั้ง Skeleton Component จาก Shadcn: npx shadcn@latest add skeleton
  2. สร้าง Component ServiceCardSkeleton ใน src/components/ServiceCardSkeleton.jsx หรือเพิ่มไว้ใน ServicesPage.jsx โดยตรงก็ได้ ให้ Skeleton มีขนาดและรูปร่างเหมือน ServiceCard จริง
  3. แก้ไข ServicesPage.jsx ให้แสดง Skeleton ตอน isLoading === true โดยใช้ Array(3).fill(0).map((_, i) => <ServiceCardSkeleton key={i} />)
  4. เพิ่ม Error State — ถ้า error ไม่เป็น null ให้แสดง Error Message และปุ่ม "ลองใหม่" (Button variant="outline")
src/components/ServiceCardSkeleton.jsx JSX
import { Skeleton } from '@/components/ui/skeleton';

export function ServiceCardSkeleton() {
  return (
    <div className="border rounded-lg p-4 space-y-3">
      <Skeleton className="h-48 w-full rounded" />
      <Skeleton className="h-6 w-3/4" />
      <Skeleton className="h-4 w-full" />
      <Skeleton className="h-10 w-24" />
    </div>
  );
}
เกณฑ์การผ่าน

เปิดหน้า Services แล้วตัด Network ใน DevTools ให้ช้า (Slow 3G) จะเห็น Skeleton 3 Card ก่อนที่ข้อมูลจะโหลด แทนที่ข้อความ "กำลังโหลด..." เมื่อจำลอง Network Error จะเห็นข้อความแสดง Error และปุ่มลองใหม่

คำแนะนำ

ใน Chrome DevTools → Network → Throttling เลือก "Slow 3G" หรือ "Custom" เพื่อทดสอบ Loading State โดยไม่ต้องดัดแปลงโค้ด หลังทดสอบเสร็จอย่าลืมเปลี่ยน Throttling กลับเป็น "No throttling"