สัปดาห์ที่ 8

TanStack Query: useQuery, useMutation & Server State Management

CLO3
📖 ทฤษฎี

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 ที่เหมาะสม

ข้อมูลที่เปลี่ยนบ่อย เช่น ราคาหุ้น ตั้ง 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}

ข้อควรระวัง — invalidateQueries

จำให้ดีว่า queryKey ที่ใส่ใน invalidateQueries ต้องตรงกับ queryKey ที่ใช้ใน useQuery เพื่อให้ refetch ถูก Query ถ้าใส่ผิดข้อมูลจะไม่อัปเดต แนะนำให้ extract queryKey เป็น constant เพื่อให้ reuse ได้โดยไม่มี typo

💻 โค้ดตัวอย่าง
main.jsx — ตั้งค่า QueryClientProvider JSX
// 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>
  );
}
ServicesPage.jsx — useQuery ดึงข้อมูล JSX
// 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>
  );
}
BookingForm.jsx — useMutation สร้างการจอง JSX
// 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

ต่อยอดจาก Lab 6 — แทนที่ fetch แบบ manual ด้วย TanStack Query เพื่อ caching อัตโนมัติ

1
ติดตั้งและตั้งค่า TanStack Query

ติดตั้ง @tanstack/react-query ใน project bookeasy (frontend) และห่อแอปด้วย QueryClientProvider

  • เปิด Terminal ใน folder bookeasy แล้วรัน npm install @tanstack/react-query
  • เปิดไฟล์ src/main.jsx เพิ่ม import QueryClient และ QueryClientProvider
  • สร้าง queryClient ด้วย new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, retry: 1 } } })
  • ห่อ <App /> หรือ <RouterProvider /> ด้วย <QueryClientProvider client={queryClient}>
Terminal bash
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

2
แปลง ServicesPage — useEffect เป็น useQuery

เปลี่ยน 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
src/pages/ServicesPage.jsx — หลังแปลง JSX
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

3
แปลง BookingForm — handleSubmit เป็น useMutation

เปลี่ยน 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
src/components/BookingForm.jsx — หลังแปลง JSX
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'] } เช่นกัน