1. Server State vs Client State — ความแตกต่างและทำไมต้องแยก
ใน React Application ข้อมูลที่แอปต้องจัดการแบ่งออกเป็น 2 ประเภทที่มีธรรมชาติแตกต่างกันโดยสิ้นเชิง ได้แก่ Client State และ Server State การเข้าใจความแตกต่างนี้เป็นพื้นฐานสำคัญก่อนเรียน TanStack Query
Client State คือข้อมูลที่อยู่ในแอปฝั่ง Browser เท่านั้น ไม่มีบนเซิร์ฟเวอร์
เช่น สถานะว่า Modal เปิดหรือปิดอยู่, ค่าใน Form ที่ผู้ใช้กำลังพิมพ์, Theme ที่เลือกไว้
ข้อมูลเหล่านี้ synchronous คือเข้าถึงได้ทันที และจัดการได้ดีด้วย useState
Server State คือข้อมูลที่มีต้นทางอยู่บนเซิร์ฟเวอร์ ต้องดึงผ่าน API เช่น รายการบริการจาก Supabase, ประวัติการจอง, โปรไฟล์ผู้ใช้ ข้อมูลเหล่านี้มีความซับซ้อนกว่ามาก เพราะ asynchronous (ต้องรอ), อาจ stale (ล้าสมัย) ได้เมื่อผู้ใช้คนอื่นแก้ไขข้อมูล, และต้องการ caching เพื่อไม่ให้ดึงซ้ำโดยไม่จำเป็น
Client State Server State
──────────────────── ────────────────────────────
• Modal open/closed • รายการบริการจาก /api/services
• Form input values • การจองทั้งหมดของผู้ใช้
• Selected tab • โปรไฟล์ผู้ใช้จาก /api/users/me
• Theme (dark/light) • ข้อมูล Dashboard, Analytics
จัดการด้วย: ปัญหาที่ต้องแก้:
useState, useReducer • Async → ต้องจัดการ loading/error
• Stale → ต้อง refetch เมื่อข้อมูลเก่า
• Cache → ไม่ดึงซ้ำโดยไม่จำเป็น
• Sync → อัปเดตทุก Component พร้อมกัน
ปัญหาคือเมื่อจัดการ Server State ด้วย useState + useEffect แบบ manual
โค้ดจะซับซ้อน ต้องเขียน loading state, error state, และ caching เอง
ในทุก Component ที่ต้องการข้อมูลจาก API ซึ่งทำให้เกิดโค้ดซ้ำซ้อนจำนวนมาก
ใช้ useState สำหรับ Client State และใช้ TanStack Query
สำหรับ Server State การแยกให้ถูกประเภทจะทำให้โค้ดสะอาดและดูแลรักษาง่ายขึ้นอย่างเห็นได้ชัด
2. TanStack Query คืออะไร — caching, background refetch, staleTime
TanStack Query (เดิมชื่อ React Query) เป็น Library ที่ออกแบบมาเพื่อจัดการ Server State โดยเฉพาะ ช่วยแก้ปัญหาทุกข้อที่กล่าวไว้ด้านบน โดยที่นักพัฒนาไม่ต้องเขียนโค้ด ซ้ำซ้อนเอง
ฟีเจอร์หลักที่ TanStack Query มีให้อัตโนมัติ:
- Automatic Caching — เก็บผลลัพธ์จาก API ไว้ใน Cache ดึงข้อมูลเดิม ซ้ำครั้งต่อไปได้ทันทีโดยไม่ต้องรอ Network
-
staleTime — กำหนดเวลาที่ Cache ยัง "fresh" เช่น
staleTime: 5 * 60 * 1000หมายความว่าถ้า Query ดึงข้อมูลมาไม่เกิน 5 นาทีที่แล้ว จะใช้ Cache แทนโดยไม่ดึงใหม่ - Background Refetch — เมื่อผู้ใช้กลับมาที่หน้าต่างหรือ Tab (window focus), TanStack Query จะ refetch ข้อมูลในพื้นหลังเงียบ ๆ เพื่อให้ข้อมูลล่าสุดเสมอ
-
Loading & Error States อัตโนมัติ —
isLoading,errorพร้อมใช้งานโดยไม่ต้องสร้าง state เอง - Deduplication — หาก Component หลายตัวดึงข้อมูล query เดียวกันพร้อมกัน TanStack Query จะส่ง HTTP Request เพียงครั้งเดียวแล้วแชร์ผลลัพธ์
ก่อนใช้ TanStack Query (manual): หลังใช้ TanStack Query:
───────────────────────────── ─────────────────────────────
const [services, setServices] const { data: services,
= useState([]); isLoading, error }
const [loading, setLoading] = useQuery({
= useState(true); queryKey: ['services'],
const [error, setError] queryFn: fetchServices,
= useState(null); });
useEffect(() => { // ✓ Cache อัตโนมัติ
fetch('/api/services') // ✓ Loading state ฟรี
.then(r => r.json()) // ✓ Error state ฟรี
.then(d => setServices(d.data)) // ✓ Background refetch
.catch(e => setError(e)) // ✓ Deduplication
.finally(() => setLoading(false))
}, []);
3. QueryClient และ QueryClientProvider
QueryClient คือ "สมอง" ของ TanStack Query ทำหน้าที่เก็บ Cache ทั้งหมดของแอป
จัดการ Query lifecycle และควบคุม defaultOptions ระดับ Global
มีเพียงตัวเดียวในแต่ละแอป สร้างครั้งเดียวที่ไฟล์ entry point (เช่น main.jsx)
QueryClientProvider คือ React Context Provider ที่ส่ง QueryClient ลงไปยัง
Component ทุกตัวในต้นไม้ Component ต้องห่อ Component ทั้งหมดของแอปด้วย Provider นี้
จึงจะใช้ useQuery หรือ useMutation ใน Component ลูกได้
defaultOptions.queries.staleTime ตั้งค่าเริ่มต้นให้ทุก Query ในแอป
ค่า 0 (default) หมายถึงข้อมูลถือว่า stale ทันทีที่ดึงมา จะ refetch ทุกครั้งที่
Component mount ใหม่ ค่าที่นิยมคือ 1000 * 60 * 5 (5 นาที) สำหรับข้อมูลที่ไม่ได้
เปลี่ยนบ่อย
defaultOptions.queries.retry กำหนดจำนวนครั้งที่ retry เมื่อ Query ล้มเหลว
ค่า default คือ 3 ครั้ง ลดเป็น 1 เพื่อให้ error ปรากฏเร็วขึ้นระหว่าง
Development
ข้อมูลที่เปลี่ยนบ่อย เช่น ราคาหุ้น ตั้ง staleTime: 0
ข้อมูลที่ค่อนข้างคงที่ เช่น รายการบริการ ตั้ง staleTime: 1000 * 60 * 5
ข้อมูลที่ไม่ค่อยเปลี่ยนเลย เช่น config ตั้ง staleTime: Infinity
4. useQuery — queryKey, queryFn, data/isLoading/error
useQuery คือ Hook หลักสำหรับดึงข้อมูลจาก Server รับ Options Object
ที่มี Parameter สำคัญ 2 ตัว ได้แก่ queryKey และ queryFn
queryKey คือ Array ที่ระบุ "ตัวตน" ของ Query ใช้เป็น Cache Key
ถ้า queryKey เหมือนกัน TanStack Query จะคืน Cache เดิม
ถ้า queryKey ต่างกัน จะถือเป็น Query ใหม่
เช่น ['services'] สำหรับ list ทั้งหมด,
['services', id] สำหรับ service รายการเดียว
queryFn คือ async function ที่ดึงข้อมูลจริง ต้อง return ข้อมูล
เมื่อสำเร็จ หรือ throw Error เมื่อล้มเหลว (TanStack Query จะ catch Error นั้น
และตั้ง error ให้อัตโนมัติ)
Return values ที่สำคัญจาก useQuery:
- data — ข้อมูลที่ได้จาก queryFn (undefined ขณะ loading)
- isLoading — true ระหว่างดึงข้อมูลครั้งแรก (ยังไม่มี Cache)
- isFetching — true ระหว่างดึงข้อมูลทุกครั้ง (รวม background refetch)
- error — Error object ถ้า queryFn throw Error
- isSuccess — true เมื่อดึงข้อมูลสำเร็จและ data พร้อมใช้
useQuery Lifecycle:
Component mount
│
▼
ตรวจ Cache ────── มี Cache (ยัง fresh) ──────► return data ทันที
│
│ ไม่มี Cache หรือ stale
▼
isLoading = true
queryFn() ──► fetch('/api/services')
│
├── สำเร็จ ──► data = result, isLoading = false, isSuccess = true
│
└── ล้มเหลว ──► error = Error, isLoading = false
(background refetch เมื่อ window focus กลับมา)
5. useMutation — mutationFn, onSuccess, invalidateQueries
useMutation ใช้สำหรับ Write Operations ที่เปลี่ยนแปลงข้อมูลบนเซิร์ฟเวอร์
เช่น POST, PUT, PATCH, DELETE ต่างจาก useQuery ที่ทำงานอัตโนมัติเมื่อ Component mount,
useMutation ต้องเรียก mutation.mutate(data) ด้วยตนเองเมื่อต้องการ
mutationFn คือ async function ที่รับข้อมูลที่ส่งมาจาก .mutate()
แล้วส่ง HTTP Request ไปยัง API ต้อง return ข้อมูลผลลัพธ์ หรือ throw Error เมื่อล้มเหลว
onSuccess callback ทำงานหลัง mutationFn สำเร็จ เหมาะสำหรับ:
- เรียก
queryClient.invalidateQueries()เพื่อ refetch ข้อมูลที่เกี่ยวข้อง - แสดง Toast หรือ Alert แจ้งผู้ใช้ว่าบันทึกสำเร็จ
- Navigate ไปหน้าอื่น เช่นหลัง Submit Form
invalidateQueries บอก QueryClient ให้ถือว่า Cache ของ Query ที่ระบุ queryKey นั้น "stale" ทันที ทำให้ Query นั้น refetch ข้อมูลใหม่จากเซิร์ฟเวอร์ในครั้งถัดไปที่ Component ที่ใช้ Query นั้นยัง mount อยู่ จะได้ข้อมูลล่าสุดโดยอัตโนมัติ
isPending เป็น true ขณะ mutationFn กำลังทำงาน ใช้ปิด Button เพื่อป้องกัน
Double Submit โดยตั้ง disabled={mutation.isPending}
จำให้ดีว่า queryKey ที่ใส่ใน invalidateQueries ต้องตรงกับ queryKey ที่ใช้ใน
useQuery เพื่อให้ refetch ถูก Query ถ้าใส่ผิดข้อมูลจะไม่อัปเดต
แนะนำให้ extract queryKey เป็น constant เพื่อให้ reuse ได้โดยไม่มี typo
// snippet 1: ตั้งค่า QueryClient ใน main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { router } from './router';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // cache 5 นาที
retry: 1,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
// snippet 2: useQuery ดึง services
import { useQuery } from '@tanstack/react-query';
async function fetchServices() {
const res = await fetch('http://localhost:8787/api/services');
if (!res.ok) throw new Error('โหลดข้อมูลล้มเหลว');
const json = await res.json();
return json.data;
}
function ServicesPage() {
const { data: services, isLoading, error } = useQuery({
queryKey: ['services'],
queryFn: fetchServices,
});
if (isLoading) return <p>กำลังโหลด...</p>;
if (error) return <p>เกิดข้อผิดพลาด: {error.message}</p>;
return (
<div>
{services.map(s => <ServiceCard key={s.id} {...s} />)}
</div>
);
}
// snippet 3: useMutation สร้าง booking
import { useMutation, useQueryClient } from '@tanstack/react-query';
function BookingForm({ serviceId }) {
const queryClient = useQueryClient();
const [form, setForm] = useState({ date: '', time: '' });
const mutation = useMutation({
mutationFn: async (booking) => {
const res = await fetch('http://localhost:8787/api/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(booking),
});
if (!res.ok) throw new Error('จองไม่สำเร็จ');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
alert('จองสำเร็จ!');
},
});
function handleSubmit(e) {
e.preventDefault();
mutation.mutate({ service_id: serviceId, booking_date: form.date, booking_time: form.time });
}
return (
<form onSubmit={handleSubmit}>
<input type="date" value={form.date} onChange={e => setForm(p=>({...p,date:e.target.value}))} />
<button disabled={mutation.isPending}>
{mutation.isPending ? 'กำลังจอง...' : 'ยืนยันการจอง'}
</button>
{mutation.isError && <p className="error">{mutation.error.message}</p>}
</form>
);
}
🧪 ปฏิบัติการ Lab 7 — BookEasy: แทนที่ fetch ด้วย TanStack Query
นำ TanStack Query มาแทนที่ useEffect + fetch ใน BookEasy frontend เพื่อให้ได้ caching อัตโนมัติ, loading/error states ที่สะอาด, และการ sync ข้อมูลหลัง mutation
ต่อยอดจาก Lab 6 — แทนที่ fetch แบบ manual ด้วย TanStack Query เพื่อ caching อัตโนมัติ
ติดตั้ง @tanstack/react-query ใน project bookeasy (frontend) และห่อแอปด้วย QueryClientProvider
- เปิด Terminal ใน folder
bookeasyแล้วรันnpm install @tanstack/react-query - เปิดไฟล์
src/main.jsxเพิ่ม importQueryClientและQueryClientProvider - สร้าง
queryClientด้วยnew QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, retry: 1 } } }) - ห่อ
<App />หรือ<RouterProvider />ด้วย<QueryClientProvider client={queryClient}>
cd bookeasy
npm install @tanstack/react-query
npm install สำเร็จและ @tanstack/react-query อยู่ใน package.json
ไฟล์ src/main.jsx มี QueryClientProvider ห่ออยู่ด้านนอกสุด
และ npx vite รันได้โดยไม่มี Error ในคอนโซล
สร้าง queryClient ด้านนอก function component เสมอ ไม่ใช่ภายใน
เพราะถ้าสร้างใน component จะสร้าง instance ใหม่ทุกครั้งที่ render ทำให้ Cache หายทุก render
เปลี่ยน ServicesPage.jsx จากการใช้ useEffect + fetch เป็น useQuery
- ลบ
useStateสำหรับservices,loading,error - ลบ
useEffectที่ดึงข้อมูล - เพิ่มฟังก์ชัน
fetchServicesที่ดึงข้อมูลจากhttp://localhost:8787/api/servicesและ throw Error เมื่อ!res.ok - ใช้
useQuery({ queryKey: ['services'], queryFn: fetchServices })แทน - ใช้
isLoadingแสดง loading indicator และerrorแสดงข้อความ error
import { useQuery } from '@tanstack/react-query';
async function fetchServices() {
const res = await fetch('http://localhost:8787/api/services');
if (!res.ok) throw new Error('โหลดข้อมูลล้มเหลว');
const json = await res.json();
return json.data;
}
export function ServicesPage() {
const { data: services, isLoading, error } = useQuery({
queryKey: ['services'],
queryFn: fetchServices,
});
if (isLoading) return <p className="loading">กำลังโหลดบริการ...</p>;
if (error) return <p className="error">เกิดข้อผิดพลาด: {error.message}</p>;
return (
<div className="services-grid">
{services.map(s => <ServiceCard key={s.id} {...s} />)}
</div>
);
}
หน้า ServicesPage แสดงรายการบริการจาก API ได้เหมือนเดิม
แต่โค้ดไม่มี useEffect และ useState สำหรับ server data แล้ว
เปิด Network tab ใน DevTools แล้ว Navigate กลับมาหน้า ServicesPage ซ้ำ →
ถ้า staleTime ยังไม่หมด ต้องไม่มี Request ใหม่ออกไป (ใช้ Cache)
ถ้า services เป็น undefined ตอน render แรก ให้ใช้
services ?? [] หรือเช็ค isLoading ก่อน map เสมอ
เพราะ data จะเป็น undefined ขณะ loading
เปลี่ยน BookingForm.jsx ให้ใช้ useMutation แทนการเรียก fetch ตรงใน handleSubmit
- เพิ่ม
useQueryClient()เพื่อเข้าถึงqueryClient - สร้าง mutation ด้วย
useMutation({ mutationFn, onSuccess }) - ใน
mutationFn: ส่ง POST ไปที่http://localhost:8787/api/bookingsพร้อม JSON body - ใน
onSuccess: เรียกqueryClient.invalidateQueries({ queryKey: ['bookings'] })แล้วแจ้งผู้ใช้ - แก้
handleSubmitให้เรียกmutation.mutate(formData)แทน fetch โดยตรง - ตั้ง
disabled={mutation.isPending}บนปุ่ม Submit เพื่อป้องกัน Double Submit
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function BookingForm({ serviceId }) {
const queryClient = useQueryClient();
const [form, setForm] = useState({ date: '', time: '' });
const mutation = useMutation({
mutationFn: async (booking) => {
const res = await fetch('http://localhost:8787/api/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(booking),
});
if (!res.ok) throw new Error('จองไม่สำเร็จ');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
alert('จองสำเร็จ!');
},
});
function handleSubmit(e) {
e.preventDefault();
mutation.mutate({
service_id: serviceId,
booking_date: form.date,
booking_time: form.time,
});
}
return (
<form onSubmit={handleSubmit}>
<input
type="date"
value={form.date}
onChange={e => setForm(p => ({ ...p, date: e.target.value }))}
required
/>
<input
type="time"
value={form.time}
onChange={e => setForm(p => ({ ...p, time: e.target.value }))}
required
/>
<button disabled={mutation.isPending}>
{mutation.isPending ? 'กำลังจอง...' : 'ยืนยันการจอง'}
</button>
{mutation.isError && (
<p className="error">{mutation.error.message}</p>
)}
</form>
);
}
กดปุ่ม "ยืนยันการจอง" → ปุ่ม disabled และแสดง "กำลังจอง..." ระหว่างรอ
เมื่อ API ตอบกลับสำเร็จ → ข้อมูลการจองในหน้า Bookings อัปเดตอัตโนมัติโดยไม่ต้อง Refresh หน้า
(เพราะ invalidateQueries บังคับ refetch)
ถ้า API ล้มเหลว → แสดงข้อความ error ใต้ปุ่ม
ตรวจสอบว่า queryKey ใน invalidateQueries ตรงกับ queryKey ใน
useQuery ที่ดึงรายการการจอง เช่นถ้า Query ดึง bookings ใช้
queryKey: ['bookings'] ก็ต้อง invalidate ด้วย { queryKey: ['bookings'] }
เช่นกัน