โหมดมืด
บทที่ 4 — Data Fetching (TanStack Query)
🟡 ระดับ: กลาง
📖 คำศัพท์รวมของบท (อ่านก่อน — บทใช้คำเหล่านี้ตลอด):
- data fetching = ดึงข้อมูลจาก server มาแสดง
- cache (แคช) = จำข้อมูลที่ดึงมาแล้วไว้ใช้ซ้ำ ไม่ต้องดึงใหม่
- query = การ "อ่าน" ข้อมูล (GET) — TanStack Query เก็บผลใน cache อัตโนมัติ
- mutation = การ "เขียน/แก้/ลบ" ข้อมูล (POST/PUT/DELETE)
- invalidation = สั่งให้ cache หมดอายุเพื่อให้ดึงใหม่ (มักทำหลัง mutation)
- dedup / deduplication (เด-ดูป) = ยุบ request ซ้ำให้เหลือครั้งเดียว — ถ้า 5 component เรียก
/api/users/1พร้อมกัน → ส่ง HTTP request จริงครั้งเดียว แล้วทั้ง 5 รับผลเดียวกัน- stale / fresh = ข้อมูลใน cache "เก่า" / "สด" — fresh ใช้ได้เลย, stale ใช้ได้แต่ refetch เบื้องหลัง
- refetch = ดึงข้อมูลใหม่ (เพราะค่าเก่า, focus หน้าต่าง, reconnect network)
- optimistic update = update UI ทันทีก่อนรอ server ตอบ (UI ตอบสนองไว) ถ้า server fail ค่อยย้อน — เรียก "optimistic" เพราะ "สมมุติล่วงหน้าว่า server จะตอบ OK" (มองโลกในแง่ดี) จึง update UI ทันที — ถ้าคาดผิด (server fail) ค่อยย้อนกลับ
- prefetch = ดึงข้อมูลล่วงหน้า ก่อน user จะเปิดหน้านั้น
- AbortController = API ของ browser ใช้ยกเลิก fetch กลางคัน — เช่น user พิมพ์ค้นหาเร็ว → request เก่ายังไม่เสร็จ แต่ส่ง request ใหม่ไปแล้ว — ยกเลิก (abort) request เก่าเพื่อไม่ให้ผลเก่ามา override ผลใหม่
- Suspense = กลไก React สำหรับ "รอ async" — เช่น โชว์ spinner ขณะรอ component โหลด แทน return ของ loading state เอง (รายละเอียดบท 9)
- UX (User Experience) = ประสบการณ์ที่ผู้ใช้ได้รับขณะใช้แอป — UX ดี = ใช้ง่าย ตอบสนองไว รู้สึกดี
📌 2 คำข้างล่างนี้เกี่ยวกับบท 11 (Next.js) — ข้ามได้ถ้ายังไม่ได้อ่านบทนั้น เจอจริงตอน §13 ของบทนี้:
- RSC (React Server Components) = component รันบน server (รายละเอียดบท 11)
- hydrate / hydration = "เติมชีวิต" ให้ HTML ที่ server ส่งมา → กลายเป็น React app ที่กดได้
🗺️ Reading order (ลำดับการอ่าน) — บทยาว ~1000 บรรทัด:
- รอบแรก: §1 (ปัญหา useEffect) ถึง §5 (Cache Invalidation) พอ — ครบความรู้ที่ใช้ทำงาน 80% ของ project
- รอบสอง (เจอ use case จริง): §6 optimistic, §8 pagination, §9 infinite scroll
- รอบสาม (พร้อม Suspense/Next.js): §13 ทั้งหมด + §7.6 persist
ทุกแอปต้อง fetch (ดึง) data จาก server — รายการ user, profile, search results
บทนี้:
- ทำไม useEffect + fetch ไม่พอ
- TanStack Query (เดิมชื่อ React Query) — library ที่ทุกคนใช้
- Query, mutation, cache, invalidation, optimistic update
- รวมกับ Spring Boot backend จากบท Spring Boot (บทที่ 6 — ข้ามได้ถ้ายังไม่ได้เรียน Spring Boot ดูหมายเหตุ Checkpoint 4.1)
1. ปัญหาของ useEffect + fetch
📌 ตัวเลือกแรกใน 2026 (เรียงตามแนะนำ):
- Next.js / Remix → Server Components +
await fetch(ไม่ต้องมี client state เลย — ดู §13)- Vite SPA → TanStack Query (เนื้อหาหลักของบทนี้)
- useEffect + fetch → ใช้ได้สำหรับ demo เล็ก ๆ หรือ one-off — ตัวอย่างข้างล่างไว้ดูเพื่อเข้าใจว่า "ทำไมถึงต้องมี TanStack Query"
tsx
function UserProfile({ id }: { id: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController(); // controller = ตัวควบคุม ใช้ยกเลิก request ได้
setLoading(true);
fetch(`/api/users/${id}`, { signal: controller.signal }) // signal = ส่งสัญญาณยกเลิก fetch ถ้า controller.abort() ถูกเรียก
.then(r => r.json())
.then(data => setUser(data))
.catch(err => {
if (err.name === 'AbortError') return; // user เปลี่ยน id ระหว่างรอ
setError(err);
})
// ⚠️ finally ยังรันหลัง abort — ใน React 18+ unmounted component warning อาจขึ้น
.finally(() => setLoading(false));
// cleanup → ยกเลิก fetch จริง ๆ (ไม่ใช่แค่ ignore ผลลัพธ์)
return () => controller.abort();
}, [id]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error</p>;
// ⚠️ ตัวอย่างนี้แสดงจุดอ่อนของ useEffect — ใช้ ?. (optional chaining) กันไว้แล้ว
// แต่ยังมีปัญหาที่ลึกกว่านั้น: ถ้า id เปลี่ยนกลางคัน error state จากรอบเก่าอาจค้างอยู่
// ทำให้เห็น "Error" ทั้งที่ request ใหม่ยังโหลดอยู่ปกติ — ต้อง reset error ใน useEffect ด้วยตัวเอง
return <div>{user?.name}</div>;
}ทำได้ — แต่ปัญหา:
- ไม่มี cache — เปลี่ยน route ไปกลับ → fetch ซ้ำ
- ไม่มี dedup — 5 component เรียก
/api/users/1พร้อมกัน → 5 request - ไม่มี refetch on focus — user สลับ tab กลับมา → ข้อมูลเก่า
- Loading/error state manual — เขียนซ้ำทุก component
- Race condition — ต้องจัดการเอง
- Update sync — แก้ user → list ไม่อัพเดท
2. TanStack Query — แก้ทุกปัญหา
bash
npm install @tanstack/react-query2.1 Setup
tsx
// main.tsx — แทนเนื้อหา main.tsx เดิมทั้งหมดด้วย snippet นี้
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // ถือว่า fresh นาน 1 นาที (`_` เป็นตัวคั่นให้อ่านง่าย = 60000 มิลลิวินาที)
refetchOnWindowFocus: true,
},
},
});
// เพิ่ม QueryClientProvider ครอบ App
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>
);2.1.1 ⭐ ทำความเข้าใจ staleTime vs gcTime ก่อนตั้งค่า
มือใหม่ส่วนใหญ่ confused 2 ค่านี้ — เปรียบเทียบกับซุปในตู้เย็นจะเห็นภาพ:
text
ปั่นซุปเสร็จ → ใส่ตู้เย็น
↓
staleTime: "สดอยู่กี่นาที?" (freshness timer — ยังไม่ต้องอุ่นซ้ำ ใช้ได้เลย)
↓
gcTime (garbage collection time — เวลา cleanup):
"เก็บในตู้เย็นได้กี่นาทีหลังไม่มีใครกิน?"
— ครบเวลานี้ → โยนทิ้งคำศัพท์: stale = "เก่า/ค้าง" (ข้อมูลที่อาจล้าสมัยแล้ว) ตรงข้ามกับ fresh = "สด/ใหม่"; staleTime = ระยะเวลาที่ยังถือว่าข้อมูลสด; gcTime (garbage collection time) = เวลาเก็บแคชไว้ก่อนลบทิ้ง
แปลเป็น TanStack Query:
staleTime= ระยะที่ "ถือว่า data ยัง fresh (สด)" — ไม่ refetch ถ้าเปิด component ใหม่gcTime= ระยะที่ "เก็บ cache ไว้" หลังจากไม่มี component ใช้ — ครบ → ลบทิ้ง
tsx
useQuery({
queryKey: ['user', id],
queryFn: fetchUser,
staleTime: 60_000, // 1 นาทียัง fresh
gcTime: 5 * 60_000, // เก็บไว้ 5 นาทีหลังไม่มีใครใช้
});ตัวอย่าง timeline:
text
t=0s เปิด UserProfile → fetch → cache, status: fresh
t=30s เปิด UserProfile อีก tab → ใช้ cache, ไม่ refetch (fresh)
t=70s refetch on focus → cache updated, status: fresh
t=130s ปิดทั้ง 2 tab → component unmount, แต่ cache ยังอยู่ (gcTime ยังไม่หมด)
t=430s gcTime หมด → cache ถูกลบ2.1.2 Decision table — ตั้ง staleTime เท่าไหร่?
| ประเภท data | staleTime แนะนำ | เหตุผล |
|---|---|---|
| Static (country list, currency code) | Infinity | ไม่เปลี่ยน — cache ตลอด session |
| Reference data (categories, tags) | 30 * 60_000 (30 นาที) | เปลี่ยนนานๆครั้ง |
| User profile (ของตัวเอง) | 5 * 60_000 (5 นาที) | เปลี่ยนผ่าน mutation เป็นหลัก |
| Common app data (post list, products) | 60_000 (1 นาที) | balance ระหว่าง fresh vs request |
| Search results | 30_000 (30 วินาที) | user คาดหวังให้ fresh แต่ไม่ต้องทุก keystroke |
| Real-time-ish (notifications, chat) | 0 + refetchInterval | ต้องการ poll |
| Stock prices, live feed | 0 | refetch ทุกครั้ง — หรือใช้ WebSocket แทน |
💡 กฎทอง: เริ่มที่
staleTime: 60_000(1 นาที) ก่อน — แล้วปรับตาม pattern จริง ถ้าเห็น "request เยอะเกินจำเป็น" → เพิ่ม staleTime ถ้าเห็น "data เก่า user complain" → ลด staleTime หรือ invalidate ตอน mutate
2.2 ตัวอย่าง — useQuery
tsx
import { useQuery } from '@tanstack/react-query';
function UserProfile({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', id],
queryFn: async () => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed');
// ✅ ใช้ Zod validate ตอน runtime — ป้องกัน server ส่งข้อมูลผิด shape
// (ถ้ายังไม่รู้จัก Zod ข้ามบรรทัดนี้ได้ — ดูบทที่ 3 §12 หรือบท Next.js §12)
return UserSchema.parse(await res.json());
},
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <div>{data?.name}</div>;
}จาก 25 บรรทัด → 12 บรรทัด — และได้ feature ฟรี:
- ✅ Cache (เปิด component อีกครั้ง → ใช้ cache ทันที + refetch ในเบื้องหลัง)
- ✅ Dedup (เรียก query เดียวกัน → 1 request)
- ✅ Refetch on focus
- ✅ Retry on error
- ✅ Loading/error state
- ✅ Cancel ตอน unmount
3. Anatomy ของ useQuery
tsx
const result = useQuery({
queryKey: ['users', { page, filter }],
queryFn: async () => fetchUsers(page, filter),
// options
enabled: !!userId, // run query เมื่อ true เท่านั้น
staleTime: 60_000, // fresh ภายในเวลานี้ (ms)
gcTime: 5 * 60_000, // เก็บใน cache นานเท่าไหร่หลังไม่ใช้
retry: 3, // retry กี่ครั้งถ้า error
refetchOnWindowFocus: true,
refetchInterval: 30_000, // poll ทุก 30 วิ
select: (data) => data.items, // transform data
});
const {
data,
isLoading, // true ครั้งแรกที่ load (ไม่มี cache)
isFetching, // true ทุกครั้งที่ refetch
isError,
error,
isSuccess,
refetch, // function refetch manually
status, // 'pending' | 'error' | 'success'
} = result;Query key — สำคัญที่สุด
- คือ array — แต่ละ element identify query
- ใช้สำหรับ:
- cache lookup
- invalidation
- dedup
tsx
queryKey: ['users'] // GET /users (all)
queryKey: ['users', userId] // GET /users/:id
queryKey: ['users', { page: 1, filter: 'a' }] // GET /users?page=1&filter=a
queryKey: ['users', userId, 'posts'] // GET /users/:id/postsConvention:
- ลำดับ general → specific
- ใช้ object ที่ stable (อย่าใส่
new Date())
4. Mutation — POST/PUT/DELETE
useQuery ใช้สำหรับ "อ่าน" ข้อมูล ส่วนการ "เปลี่ยน" ข้อมูล (POST/PUT/DELETE) ใช้ useMutation:
tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newUser: CreateUserRequest) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
if (!res.ok) throw new Error('Failed');
return res.json();
},
onSuccess: (createdUser) => {
// invalidate cache → refetch list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (error) => {
console.error(error);
},
});
const onSubmit = (data: CreateUserRequest) => {
mutation.mutate(data);
};
return (
<form onSubmit={(e) => { e.preventDefault(); /* gather form data + call mutation.mutate(data) */ }}>
...
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create'}
</button>
{mutation.error && <p>{mutation.error.message}</p>}
</form>
);
}useMutation states
tsx
mutation.isPending // กำลัง mutate
mutation.isSuccess
mutation.isError
mutation.data // ผลลัพธ์ครั้งล่าสุด
mutation.error
mutation.reset() // clear statemutate vs mutateAsync
tsx
mutation.mutate(input); // fire and forget (ยิงแล้วไม่รอผล — ไม่ใช้ await)
mutation.mutate(input, {
onSuccess: (data) => { ... }, // callback per call
onError: (err) => { ... },
});
// await ได้
try {
const result = await mutation.mutateAsync(input);
} catch (err) {
// handle
}5. Cache Invalidation
หลัง mutate — users list ใน cache เก่าแล้ว → invalidate:
tsx
queryClient.invalidateQueries({ queryKey: ['users'] });— ทุก query ที่ key ขึ้นต้นด้วย ['users'] จะ refetch
Partial invalidation
tsx
// invalidate /users/1 อย่างเดียว
queryClient.invalidateQueries({ queryKey: ['users', userId] });
// invalidate ทุก users query
queryClient.invalidateQueries({ queryKey: ['users'] });
// invalidate ตาม predicate
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'users',
});setQueryData — update cache โดยไม่ refetch
tsx
queryClient.setQueryData(['user', userId], updatedUser);ใช้เมื่อ server return updated entity → ลด round trip (round trip = 1 รอบที่ browser ส่ง request ไป server และรอรับ response กลับ)
🛑 หยุดตรงนี้ในรอบแรก — ถ้าเพิ่งอ่านบทนี้ครั้งแรก เนื้อหาถึงตรงนี้ (§1-5) ครบพอใช้งานจริงแล้ว ไปต่อบทที่ 5 (Routing) ได้เลย แล้วค่อยกลับมาอ่าน §6 เป็นต้นไปตอนเจอ use case จริง (ดู "Reading order" ด้านบนสุดของบท)
6. Optimistic Update
อัพเดท UI ทันทีก่อน server response → ถ้า fail rollback
tsx
const toggleMutation = useMutation({
mutationFn: (id: number) =>
fetch(`/api/todos/${id}/toggle`, { method: 'POST' }),
onMutate: async (todoId) => {
// 1. cancel queries ที่อาจ overwrite
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 2. snapshot
const previous = queryClient.getQueryData<Todo[]>(['todos']);
// 3. optimistic update
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map(t => t.id === todoId ? { ...t, done: !t.done } : t)
);
// return context for rollback
return { previous };
},
onError: (err, todoId, context) => {
// rollback
if (context?.previous) {
queryClient.setQueryData(['todos'], context.previous);
}
},
onSettled: () => {
// refetch เพื่อ sync กับ server
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});UX ดีมาก — click → response ทันที — รู้สึก instant
7. Dependent Query
บางครั้ง query ที่สองต้องรอผลของ query แรกก่อน เช่น ดึง user id ก่อนแล้วค่อยดึง order ของ user นั้น ใช้ option enabled ป้องกันการยิง request ด้วยค่า undefined:
tsx
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPostsByUser(user!.id),
enabled: !!user, // run เมื่อ user มี
});7.5 useQueries — query หลายตัวพร้อมกัน (dynamic count)
useQuery สำหรับ 1 query — แล้วถ้าต้อง fetch หลาย entity ที่จำนวนไม่รู้ตอน compile time (ตอน build โค้ดก่อนรัน)?
📖 compile time = ตอนที่ build โค้ด (ก่อนรัน), runtime = ตอนที่แอปรันจริงในเบราว์เซอร์
ตัวอย่าง: หน้า dashboard ต้องโหลด user ของแต่ละ projectId ที่ user ติดตาม (จำนวนไม่แน่นอน)
tsx
import { useQueries } from '@tanstack/react-query';
// Spinner = loading component ที่คุณสร้างเอง (หรือใช้จาก UI library)
// User = TypeScript type/interface สำหรับ user object
// usersApi = api layer ที่สร้างใน §10
function MultiUserView({ userIds }: { userIds: string[] }) {
const results = useQueries({
queries: userIds.map(id => ({
queryKey: ['user', id],
queryFn: () => usersApi.get(id),
staleTime: 60_000,
})),
});
// isPending = ยังไม่มี data เลย (ไม่ว่ากำลัง fetch อยู่หรือไม่)
// 💡 ข้ามได้ถ้ายังไม่ต้องแยกสองค่านี้ — isLoading = isPending && isFetching
// (true เฉพาะตอน fetch ครั้งแรกที่ยังไม่มี data เลย) ต่างจาก isPending ที่ true
// ตราบใดที่ยังไม่มี data เลย ไม่ว่าจะกำลัง fetch อยู่หรือไม่
const isPending = results.some(r => r.isPending);
const users = results.map(r => r.data).filter(Boolean) as User[];
if (isPending) return <Spinner />;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}ที่ดี:
- dedup ของ TanStack Query ยังทำงาน — ถ้า userIds มี ['1', '2', '1'] → fetch แค่ 2 ครั้ง
- ใช้ cache ที่ shared กับ
useQuery({ queryKey: ['user', id] })ที่อื่นในแอป - combine results ทำใน component (เลือก isLoading, error, data ตาม pattern ที่ต้องการ)
ห้ามทำ:
tsx
// ❌ ผิดกฎ hook — เรียก useQuery ใน loop (eslint-plugin-react-hooks จะ flag ทันที)
function Bad({ userIds }: { userIds: string[] }) {
return userIds.map(id => {
const { data } = useQuery({ queryKey: ['user', id], queryFn: () => usersApi.get(id) });
return data;
});
}→ ใช้ useQueries แทนเสมอเมื่อจำนวนไม่คงที่
7.6 Persistent Cache — survive reload + sync across tabs
ปัญหาที่เจอ: user reload page → cache หายหมด → ทุก query refetch → first paint ช้า
📖 first paint = การวาดหน้าแรกสุด — เวลาที่ user เห็นจอจริง ๆ ครั้งแรก (ยิ่งเร็วยิ่งดี)
@tanstack/query-sync-storage-persister = save cache ลง localStorage + restore ตอน app เริ่ม
bash
npm install @tanstack/query-sync-storage-persister @tanstack/react-query-persist-clienttsx
// main.tsx
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 1000 * 60 * 60 * 24, // ⭐ ต้อง >= maxAge ของ persister
},
},
});
const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'react-query-cache',
});
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24, // เก็บไว้ 24 ชม.
buster: 'v1', // เปลี่ยนตอน schema เปลี่ยน → invalidate cache เก่า
}}
>
<App />
</PersistQueryClientProvider>ลอง reload page แล้วดูว่า cache ฟื้นไวแค่ไหน
⚠️ อย่าใส่ sensitive data (ข้อมูลอ่อนไหว เช่น medical = สุขภาพ, financial = การเงิน) ใน cache ที่ persist — localStorage ไม่ encrypted สำหรับข้อมูลแบบนี้ → ปิด persist สำหรับ query key นั้น
⚠️ localStorage มี limit ~5MB — ถ้า cache ใหญ่มากหรือต้องการ non-blocking I/O ให้ใช้
@tanstack/query-async-storage-persister+ IndexedDB แทน (sync persister จะ block main thread ขณะ read/write)
7.6.1 Selective persist — บาง query เท่านั้น
tsx
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
dehydrateOptions: {
shouldDehydrateQuery: (q) => {
// persist เฉพาะ products + categories
const key = q.queryKey[0];
return key === 'products' || key === 'categories';
},
},
}}
>7.6.2 Sync ระหว่าง tabs — broadcastQueryClient
📌 package ชื่อมี
experimental= API อาจเปลี่ยนได้ในเวอร์ชันถัดไป — ใช้งานได้แต่ควรติดตาม changelog ก่อน upgrade
bash
npm install @tanstack/query-broadcast-client-experimentaltsx
import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental';
broadcastQueryClient({ queryClient, broadcastChannel: 'my-app' });→ user แก้ user profile ใน tab A → tab B เห็น update ทันที
📖 BroadcastChannel API = API ของ browser ที่ส่งข้อความระหว่าง tab ของเว็บเดียวกัน — ใช้สื่อสาร tab-to-tab โดยไม่ต้องผ่าน server
8. Pagination
ใส่ page ลงใน queryKey เพื่อให้ cache แยกต่อหน้า และใช้ placeholderData: (prev) => prev เพื่อไม่ให้หน้ากระพริบระหว่างโหลด:
tsx
function UserList() {
const [page, setPage] = useState(0);
const { data, isLoading } = useQuery({
queryKey: ['users', { page }],
queryFn: () => fetchUsers(page),
placeholderData: (prev) => prev, // keep old data while loading new
});
return (
<div>
{isLoading && <p>Loading...</p>}
<ul>
{data?.content.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}>
Prev
</button>
<button onClick={() => setPage(p => p + 1)} disabled={!data || page >= data.totalPages - 1}>
Next
</button>
</div>
);
}placeholderData: (prev) => prev — ไม่เคลียร์ data ระหว่างเปลี่ยน page — UX ลื่นไหล (user experience ไม่กระตุก)
9. Infinite Scroll
useInfiniteQuery เก็บผลเป็น "pages" หลายชุด ใช้ getNextPageParam คำนวณ cursor ของหน้าถัดไป และ fetchNextPage ดึงเพิ่มต่อท้าย:
tsx
import { useInfiniteQuery } from '@tanstack/react-query';
function FeedList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam = 0 }) => fetchFeed(pageParam),
initialPageParam: 0,
// ตัวอย่างนี้ใช้ offset-based pagination (page index = จำนวน page ที่โหลดไปแล้ว)
// ถ้า backend ใช้ cursor-based pagination → เปลี่ยนเป็น: lastPage.nextCursor ?? undefined
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length : undefined,
});
return (
<div>
{data?.pages.flatMap(p => p.items).map(item => (
<Item key={item.id} {...item} />
))}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</button>
</div>
);
}10. รวมเป็น API layer
รวม logic การเรียก API ไว้ใน "API layer" ที่เดียว โดยมี helper http<T> กลางที่จัดการ base URL, header, error, และ JSON parse:
⚠️ ระวัง: เก็บ token ใน localStorage เสี่ยง XSS — โค้ดตัวอย่างข้างล่างเก็บ auth token ไว้ใน
localStorageเพื่อความง่ายตอนเรียนรู้ แต่ใน production ถ้าแอปมีช่องโหว่ XSS (ถูกแทรก JavaScript แปลกปลอมเข้ามารันในหน้าเว็บ) ผู้โจมตีจะดึง token จากlocalStorageไปใช้แทนตัว user ได้ทันที — ทางที่ปลอดภัยกว่าคือใช้ httpOnly cookie (cookie ที่ JavaScript อ่านไม่ได้ อ่านได้แค่ browser กับ server) แทน แล้วเรียกfetchด้วยcredentials: 'include'เพื่อให้ browser แนบ cookie ไปเองอัตโนมัติ
tsx
// api/users.ts
// ใช้ environment variable — ตั้งค่าใน .env: VITE_API_BASE_URL=http://localhost:8080
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080';
async function http<T>(path: string, options?: RequestInit): Promise<T> {
// token ต้องถูก set ไว้ใน localStorage ก่อนแล้ว (เช่น ตอน login)
const token = localStorage.getItem('token');
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
// Authorization: Bearer = รูปแบบ header มาตรฐานสำหรับส่ง token ยืนยันตัวตนไปกับ request
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new ApiError(res.status, error.message || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// ApiError = custom error class ที่เพิ่ม status code เข้าไป
// extends Error = สืบทอดจาก Error มาตรฐาน — เพื่อให้แยกแยะ error จาก API ออกจาก error อื่น
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
// type สำหรับ paginated response — Spring Boot ส่ง Page object มาแบบนี้
type Page<T> = { content: T[]; totalPages: number; totalElements: number; number: number };
// type สำหรับ request body ตอน create/update user
type CreateUserRequest = { name: string; email: string };
type UpdateUserRequest = Partial<CreateUserRequest>;
export const usersApi = {
list: (page: number, size: number) =>
http<Page<User>>(`/users?page=${page}&size=${size}`),
get: (id: string) => http<User>(`/users/${id}`),
create: (data: CreateUserRequest) =>
http<User>('/users', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: UpdateUserRequest) =>
http<User>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: string) => http<void>(`/users/${id}`, { method: 'DELETE' }),
};tsx
// hooks/useUsers.ts
export function useUsers(page: number, size: number = 10) {
return useQuery({
queryKey: ['users', { page, size }],
queryFn: () => usersApi.list(page, size),
});
}
export function useUser(id: string) {
return useQuery({
queryKey: ['users', id],
queryFn: () => usersApi.get(id),
enabled: !!id,
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: usersApi.create,
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: usersApi.delete,
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
});
}ใน component:
tsx
function UserList() {
const [page, setPage] = useState(0);
const { data, isLoading } = useUsers(page);
const deleteMutation = useDeleteUser();
if (isLoading) return <Spinner />;
return (
<ul>
{data?.content.map(u => (
<li key={u.id}>
{u.name}
<button onClick={() => deleteMutation.mutate(u.id)}>Delete</button>
</li>
))}
</ul>
);
}โค้ดแอปสั้นมาก — logic API/cache อยู่ใน hook
11. React Query Devtools
เพิ่ม component เดียวก็มี panel ใน browser แสดงทุก query/mutation, สถานะ cache, และเวลา stale:
bash
npm install @tanstack/react-query-devtoolstsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>มี UI ใน browser ดู cache, queries, mutations — debug ง่ายมาก
12. Error handling
React Query จัดการ error ได้ 3 ระดับ:
- Global — จับทุก query/mutation ที่ fail ไว้ที่เดียว เช่น แสดง toast
- Per-query — กำหนด retry logic เฉพาะ เช่น ไม่ retry เมื่อ 404
- Error boundary — โยน error ขึ้นไปให้ UI fallback จัดการ
Global error
tsx
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
console.error('Query failed:', error);
// toast.error(error.message);
},
}),
mutationCache: new MutationCache({
onError: (error, variables, context, mutation) => {
console.error('Mutation failed:', error);
},
}),
});Per query
tsx
const { data, error } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
});Error boundary
bash
npm install react-error-boundarytsx
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
// ห่อใน function component — JSX ต้องอยู่ใน return ของ component
function AppWithErrorBoundary() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }: FallbackProps) => (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
>
<YourApp />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
// ใน query — opt in to throw on error
useQuery({
...,
throwOnError: true,
});13. Suspense
React 18+ + TanStack Query → ใช้ useSuspenseQuery แทนการเช็ค isLoading เอง <Suspense> จับ loading state ที่ระดับบน และ TypeScript รู้ว่า data ไม่มีทางเป็น null:
tsx
const { data } = useSuspenseQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
// data ไม่เป็น null แน่ — Suspense จับ loading statetsx
<Suspense fallback={<Spinner />}>
<UserProfile id={id} />
</Suspense>13.1 use() hook (React 19) — อ่าน Promise/Context ตรงๆ
🔴 §13.1 + §13.2 ข้ามได้ถ้ายังใช้ Vite + SPA — สำหรับ Next.js/Remix v7+ (RSC) เป็นหลัก รายละเอียดเต็มอยู่ในบท 11
React 19 มี hook ใหม่ชื่อ use() (เรียกสั้นๆ แต่ทรงพลัง) — รับ Promise หรือ Context แล้วคืนค่า:
- ถ้าเป็น Promise ที่ยัง pending → throw ไปให้
<Suspense>ที่ใกล้สุดจับ (เหมือน useSuspenseQuery) - ถ้า resolved แล้ว → return value
- ถ้า rejected → throw ไปให้
<ErrorBoundary>จับ
tsx
import { use, Suspense } from 'react';
// หมายเหตุ: `cache` ไม่ได้ import ที่นี่ — cache() เป็น Server-only API ใช้ได้เฉพาะ Server Components (ดูบท 11)
// promise สร้างที่ parent (ห้ามสร้างใน render ของ component ที่ use() — จะ loop)
function UserProfile({ promise }: { promise: Promise<User> }) {
const user = use(promise);
return <div>{user.name}</div>;
}
// ✅ วิธี A — สร้าง promise นอก component (module-level singleton) → render กี่ครั้งก็ promise เดิม
const userPromise = fetchUser(1);
function Page() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile promise={userPromise} />
</Suspense>
);
}
// ✅ วิธี B — ใน Server Component (Next.js App Router) สร้าง promise ใน render ได้ แล้ว pass ลง Client Component:
// async function ServerPage() {
// const promise = fetchUser(1); // run บน server ครั้งเดียว
// return <Suspense fallback={<Spinner/>}><UserProfile promise={promise} /></Suspense>;
// }
// ❌ ห้าม: สร้าง promise ใน Client Component body ตรง ๆ — ทุก re-render = promise ใหม่ = infinite loop⚠️ ข้อแตกต่างจาก hook อื่น:
use()เรียกในif/forได้ (early return) ตรงข้ามกับ rule of hooks ปกติ — เป็น exception ที่ React 19 อนุญาตเฉพาะuse()
เมื่อไหร่ใช้ use() vs TanStack Query:
use()ดีเมื่อ promise มาจาก Server Component (RSC) → ส่ง promise ข้าม server→client ได้- TanStack Query ดีเมื่อต้องการ cache/refetch/invalidate ระดับ app (client-side)
- ใน Next.js 15 App Router → ใช้ทั้งคู่: Server Component fetch ส่ง promise → Client Component
use()
13.2 React Server Components mental model สำหรับ data fetching
ใน RSC (Next.js App Router, Remix v7+) "Server Component" run บน server ครั้งเดียว — fetch ตรงๆ ได้เลย ไม่ต้องใช้ useQuery:
tsx
// app/users/[id]/page.tsx — Server Component (ไม่มี 'use client')
import { notFound } from 'next/navigation';
// ⭐ Next.js 15: params เป็น Promise — ต้อง await ก่อนใช้
export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
// ↑ run บน server: เข้า DB ตรงๆ ได้ ไม่มี API layer
// ✅ ต้อง check null — findUnique คืน null ถ้าไม่พบ user
if (!user) notFound(); // notFound() จาก 'next/navigation' — แสดงหน้า 404
return <h1>{user.name}</h1>;
}⚠️ Next 15 breaking change:
params,searchParams,cookies(),headers()กลายเป็น Promise ต้องawait— Next 14 เป็น sync ใช้params.idตรง ๆ ได้
ข้อดี:
- ไม่มี JS เกี่ยวกับ fetch ส่งไป client (bundle เล็กลง)
- ไม่มี loading state (server รอ async/await เสร็จก่อนส่ง HTML)
- Type safe end-to-end (ตั้งแต่ต้นจนจบ — จาก DB ถึง UI ใช้ type เดียวกันได้ ไม่ต้องมี API contract แยก — เช่น ถ้า DB เปลี่ยน column จาก
user_nameเป็นfull_nameTypeScript จะแจ้ง error ทุกจุดที่ใช้ทันที ไม่ต้องหาเอง)
ข้อจำกัด:
- ไม่มี interactivity (ใช้ useState/useEffect ไม่ได้)
- Re-fetch ต้อง revalidate path/tag จาก server
- ต้องอยู่บน framework ที่รองรับ RSC (Vite SPA ใช้ไม่ได้)
เลือกยังไง:
| Use case | ใช้ |
|---|---|
| SPA (Vite) | TanStack Query |
| Next.js App Router — initial load | Server Component + await fetch |
| Next.js App Router — interactive list (filter/sort/paginate) | TanStack Query ใน Client Component |
| ผสม: SSR แล้ว hydrate ต่อ | Server Component prefetchQuery → <HydrationBoundary> → Client useQuery |
14. Best Practice
✅ Query key เป็น array, structure ดี
tsx
['users']
['users', userId]
['users', userId, 'posts']
['users', { filter, page }]✅ encapsulate ใน custom hook
tsx
// ❌ scatter ทั่ว component
useQuery({ queryKey: ['user', id], queryFn: ... })
// ✅ centralize
function useUser(id) {
return useQuery({ queryKey: ['user', id], queryFn: () => api.get(id) });
}✅ Invalidate ใน mutation onSuccess
✅ staleTime ตามการใช้งาน
- Static (country list) → infinite
- Real-time (orders) → 0 หรือ poll
- Common case → 30-60s
❌ อย่าใช้ useState สำหรับ server data
tsx
// ❌
const [users, setUsers] = useState([]);
useEffect(() => { fetch().then(setUsers); }, []);
// ✅
const { data: users } = useQuery({ ... });15. ตัวอย่างเต็ม — Todo app + Spring Boot backend
ตัวอย่าง Todo app ที่ต่อกับ backend จริง ครบ API layer, custom hook, invalidation และ optimistic update:
⚠️ ย้ำอีกครั้ง:
httphelper ด้านล่างสืบทอด pattern เก็บ token ในlocalStorageจาก §10 — เสี่ยง XSS ตามที่เตือนไว้ด้านบน production ควรใช้ httpOnly cookie แทน
tsx
// api/todos.ts
import { http } from './http'; // http มาจาก api layer ที่สร้างใน §10 (api/http.ts หรือ api/users.ts)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
type Todo = { id: number; title: string; done: boolean };
export const todosApi = {
list: () => http<Todo[]>('/todos'),
create: (title: string) => http<Todo>('/todos', {
method: 'POST',
body: JSON.stringify({ title }),
}),
toggle: (id: number) => http<Todo>(`/todos/${id}/toggle`, { method: 'POST' }),
delete: (id: number) => http<void>(`/todos/${id}`, { method: 'DELETE' }),
};
// hooks/useTodos.ts
export function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: todosApi.list,
});
}
export function useCreateTodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: todosApi.create,
onSuccess: () => qc.invalidateQueries({ queryKey: ['todos'] }),
});
}
export function useToggleTodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: todosApi.toggle,
onMutate: async (id: number) => {
await qc.cancelQueries({ queryKey: ['todos'] });
const prev = qc.getQueryData<Todo[]>(['todos']);
qc.setQueryData<Todo[]>(['todos'], (old) =>
old?.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(['todos'], ctx.prev);
},
onSettled: () => qc.invalidateQueries({ queryKey: ['todos'] }),
});
}
// TodoApp.tsx
function TodoApp() {
const { data: todos, isLoading } = useTodos();
const createMutation = useCreateTodo();
const toggleMutation = useToggleTodo();
const [input, setInput] = useState('');
if (isLoading) return <p>Loading...</p>;
return (
<div>
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && input.trim()) {
createMutation.mutate(input);
setInput('');
}
}}
/>
<ul>
{todos?.map(t => (
<li key={t.id}>
<input
type="checkbox"
checked={t.done}
onChange={() => toggleMutation.mutate(t.id)}
/>
<span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>
{t.title}
</span>
</li>
))}
</ul>
</div>
);
}16. Checkpoint
📝 หมายเหตุ: checkpoint บทนี้ ตั้งใจไม่มีเฉลย ให้ลองทำเองก่อน ถ้าติดให้ย้อนไปดูตัวอย่างโค้ดในบทแล้วดัดแปลง
🛠️ Checkpoint 4.1 — User list
ใช้ Spring Boot backend จากบทที่ 6 — fetch list, create, update, delete user
- จัดเป็น api layer + hook layer + UI
📌 ยังไม่ได้เรียน Spring Boot? ใช้
json-serverแทนได้:npx json-server --watch db.json --port 8080(สร้างไฟล์db.jsonที่มี{ "users": [] }) — ได้ REST API สำหรับทดสอบทันที
🛠️ Checkpoint 4.2 — Optimistic toggle
ทำ Like button — เปลี่ยน UI ทันที, ถ้า fail rollback
🛠️ Checkpoint 4.3 — Search with debounce
Search box ที่ debounce 300ms → useQuery ด้วย queryKey ที่มี search term — cache แต่ละ search
17. สรุปบท
✅ useEffect + fetch เพียงพอใน demo — production ต้องการ cache, dedup, sync ✅ TanStack Query เป็น standard ปี 2026 ✅ useQuery({ queryKey, queryFn }) — fetch + cache + sync ✅ useMutation({ mutationFn, onSuccess }) — POST/PUT/DELETE ✅ queryClient.invalidateQueries() หลัง mutation ✅ Optimistic update: onMutate + onError rollback + onSettled refetch ✅ Pagination: placeholderData: prev ✅ Infinite scroll: useInfiniteQuery ✅ Encapsulate API + hook — component สะอาด ✅ Devtools ดูสถานะ cache realtime ✅ TanStack Query v5+ features: useSuspenseQuery (รวม Suspense — ดู §13), useSuspenseInfiniteQuery, useMutationState (track mutation ข้าม component) ✅ RSC integration: HydrationBoundary — prefetch ใน Server Component แล้ว hydrate ใน Client (ดูบท 11) ✅ Query options factory pattern — type-safe + reusable query definition (ตัวอย่างข้างล่าง)
tsx
// Modern pattern: query options factory
import { queryOptions } from '@tanstack/react-query';
export const userQueries = {
detail: (id: string) => queryOptions({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
staleTime: 60_000,
}),
};
// usage
const { data } = useQuery(userQueries.detail('1'));
// prefetch
queryClient.prefetchQuery(userQueries.detail('1'));