Skip to content

บทที่ 4 — Data Fetching (TanStack Query)

← บทที่ 3 | สารบัญ | บทที่ 5: Routing →

🟡 ระดับ: กลาง

📖 คำศัพท์รวมของบท (อ่านก่อน — บทใช้คำเหล่านี้ตลอด):

  • 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

บทนี้:

  1. ทำไม useEffect + fetch ไม่พอ
  2. TanStack Query (เดิมชื่อ React Query) — library ที่ทุกคนใช้
  3. Query, mutation, cache, invalidation, optimistic update
  4. รวมกับ Spring Boot backend จากบท Spring Boot (บทที่ 6 — ข้ามได้ถ้ายังไม่ได้เรียน Spring Boot ดูหมายเหตุ Checkpoint 4.1)

1. ปัญหาของ useEffect + fetch

📌 ตัวเลือกแรกใน 2026 (เรียงตามแนะนำ):

  1. Next.js / Remix → Server Components + await fetch (ไม่ต้องมี client state เลย — ดู §13)
  2. Vite SPATanStack Query (เนื้อหาหลักของบทนี้)
  3. 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-query

2.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 เท่าไหร่?

ประเภท datastaleTime แนะนำเหตุผล
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 results30_000 (30 วินาที)user คาดหวังให้ fresh แต่ไม่ต้องทุก keystroke
Real-time-ish (notifications, chat)0 + refetchIntervalต้องการ poll
Stock prices, live feed0refetch ทุกครั้ง — หรือใช้ 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/posts

Convention:

  • ลำดับ 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 state

mutate 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-client
tsx
// 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-experimental
tsx
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-devtools
tsx
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-boundary
tsx
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 state
tsx
<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_name TypeScript จะแจ้ง 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 loadServer 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:

⚠️ ย้ำอีกครั้ง: http helper ด้านล่างสืบทอด 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'));

→ ไปบทที่ 5: Routing