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),
); });
} }
─────────────────────────────────────────────────เมื่อคุณพบว่าเขียน 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
หลักการ "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 — ตั้งค่า
onErrorcallback ใน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
},
}),
});
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 /> จริง
Shadcn Skeleton Component ใช้ CSS Animation pulse ซึ่งทำให้
Opacity กระพริบอย่างสม่ำเสมอ ซึ่งบอกผู้ใช้ว่ากำลังโหลดโดยไม่รบกวนสายตา
ใช้ Skeleton หลายอันซ้อนกันให้มีขนาดตรงกับข้อความหรือรูปภาพจริง
เช่น h-6 w-3/4 สำหรับ Title และ h-4 w-full สำหรับ Body Text
// 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),
});
}
// 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'] }),
});
}
// 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 — Refactor โค้ด data fetching ทั้งหมดเป็น custom hooks เพื่อ reusability
สร้างโฟลเดอร์ src/hooks/ และสร้างไฟล์ Custom Hook ตาม snippets ด้านบน:
-
สร้าง
src/hooks/useServices.js— ส่งออกuseServices()และuseService(id)โดย WrapuseQueryจาก React QueryuseServices()— ดึงบริการทั้งหมด (queryKey: ['services'])useService(id)— ดึงบริการเดียวตาม id (enabled: Boolean(id))
-
สร้าง
src/hooks/useBookings.js— ส่งออกuseBookings()และuseCreateBooking()useBookings()— ดึงการจองของผู้ใช้ปัจจุบัน (ต้องล็อกอินก่อน)useCreateBooking()— Mutation สำหรับสร้างการจองใหม่ พร้อมonSuccessinvalidate 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
แทนที่การเรียก useQuery และ useMutation โดยตรงใน Component
ด้วย Custom Hooks ที่สร้างไว้ใน Task 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()
- ลบ
-
แก้ไข
src/pages/ServiceDetailPage.jsx:- ใช้
useService(id)แทนuseQueryโดยตรง - ดึง
idจาก Route Params ตามเดิม
- ใช้
-
แก้ไข
src/components/BookingForm.jsxหรือBookingDialog.jsx:- ใช้
useCreateBooking()แทนuseMutationโดยตรง - ลบ Query Key และ QueryFn ออกจาก Component
- ใช้
แอปทำงานได้เหมือนเดิมทุกฟังก์ชัน (แสดงบริการ, ดูรายละเอียด, จองได้)
โค้ดใน ServicesPage ไม่มี queryKey หรือ queryFn โดยตรงอีกต่อไป
ใช้ git diff ตรวจสอบว่าโค้ดในแต่ละไฟล์สั้นลงและอ่านง่ายขึ้น
ถ้า Component ยังมี queryKey อยู่ แสดงว่า Refactor ยังไม่สมบูรณ์
แทนที่ข้อความ "กำลังโหลด..." ด้วย Skeleton UI ที่สวยงามกว่า โดยใช้ Shadcn Skeleton Component:
-
ติดตั้ง Skeleton Component จาก Shadcn:
npx shadcn@latest add skeleton -
สร้าง Component
ServiceCardSkeletonในsrc/components/ServiceCardSkeleton.jsxหรือเพิ่มไว้ในServicesPage.jsxโดยตรงก็ได้ ให้ Skeleton มีขนาดและรูปร่างเหมือนServiceCardจริง -
แก้ไข
ServicesPage.jsxให้แสดง Skeleton ตอนisLoading === trueโดยใช้Array(3).fill(0).map((_, i) => <ServiceCardSkeleton key={i} />) -
เพิ่ม Error State — ถ้า
errorไม่เป็น null ให้แสดง Error Message และปุ่ม "ลองใหม่" (Button variant="outline")
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"