Skip to content

บทที่ 1 — เชื่อม React กับ Spring Boot

← บทที่ 0 | สารบัญ | บทที่ 2 →

บทนี้ลงมือทำเรื่องที่สำคัญที่สุด: ทำให้ React คุยกับ Spring Boot ได้

หลังจบบท คุณจะ:

  • เข้าใจ + แก้ปัญหา CORS
  • ตั้ง Vite proxy สำหรับ dev
  • ออกแบบ API layer ที่ scalable
  • จัดการ environment variable (dev/staging/prod)
  • Handle error แบบ consistent ทั้ง stack

1. สร้าง Project (recap)

บทนี้จะเชื่อม frontend (React) กับ backend (Spring Boot) + database ให้ทำงานด้วยกันจริง — เริ่มจาก setup ทั้ง 3 ส่วนให้รันได้ก่อน (recap จากบทก่อน) แล้วค่อยแก้ปัญหาที่เจอตอนต่อกัน เช่น CORS:

Backend

ขั้นตอนสร้างโปรเจกต์ผ่าน Spring Initializr (start.spring.io = เว็บสร้างโปรเจกต์ Spring เริ่มต้นให้ฟรี):

  1. เปิดเว็บ https://start.spring.io
  2. เลือก Project: Maven, Language: Java, Spring Boot: 3.3+ (เลขล่าสุดที่ไม่ใช่ SNAPSHOT)
  3. กรอก Group: com.example, Artifact: backend, Java: 21
  4. คลิกปุ่ม ADD DEPENDENCIES ทางขวา แล้วเพิ่ม: Spring Web, Spring Data JPA, PostgreSQL Driver, Validation, Lombok
  5. กดปุ่ม GENERATE สีเขียวข้างล่าง → จะได้ไฟล์ backend.zip
  6. แตก zip ไปไว้ในโฟลเดอร์โปรเจกต์ของคุณ (เช่น C:\projects\backend)
bash
cd backend
./mvnw spring-boot:run
# → http://localhost:8080

📌 Lombok คืออะไร (จะเจอบ่อยมากในบทถัดไป): Lombok เป็นไลบรารีที่ช่วย "สร้างโค้ดซ้ำ ๆ ให้อัตโนมัติ" ผ่าน annotation (คำสั่งขึ้นต้นด้วย @) เวลาเห็น @RequiredArgsConstructor บนคลาส = Lombok สร้าง constructor ที่รับ field แบบ final ทั้งหมดให้เอง (ไม่ต้องเขียน constructor เอง — ใช้คู่กับ dependency injection ของ Spring) และเวลาเห็นตัวแปร log ลอย ๆ ในคลาส = ต้องมี annotation @Slf4j บนคลาสนั้น Lombok ถึงจะสร้างตัวแปร log (ตัว logger ไว้พิมพ์ข้อความลง log) ให้ — ถ้าไม่มี @Slf4j จะ compile ไม่ผ่าน

Dependency injection (DI) = รูปแบบที่ "ไม่สร้างของที่ต้องใช้เอง แต่ให้ framework ฉีดเข้ามาให้" เช่น controller ที่ต้องใช้ service ก็แค่ประกาศ private final UserService service; ใน constructor แล้ว Spring จะหา bean (ตัวอินสแตนซ์ที่ Spring ดูแล) มาใส่ให้เอง ทำให้ทดสอบและสลับ implementation ได้ง่าย — อ่านละเอียดในบท Spring Boot

Frontend

bash
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install
npm run dev
# → http://localhost:5173

📌 ทำไมมี -- (dash 2 อัน) ในคำสั่ง? -- คือ separator (ตัวคั่น) ของ npm บอกว่า "หลังจากนี้คือ argument ของคำสั่งที่ npm กำลังเรียก ไม่ใช่ของ npm เอง" — ในที่นี้คือบอก create vite ว่า "ใช้ template ชื่อ react-ts" ถ้าไม่ใส่ -- npm จะคิดว่า --template เป็น flag ของ npm และไม่ส่งต่อให้ Vite

Database

yaml
# docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:
bash
docker compose up -d db

Part 1: CORS

2. ปัญหา CORS ครั้งแรก

ปัญหาแรกที่ทุกคนเจอตอนต่อ frontend กับ backend คนละ port (พอร์ต = หมายเลขช่องทางของเซิร์ฟเวอร์บนเครื่องเดียวกัน) คือ CORS (อ่านว่า "คอร์ส" — ย่อจาก Cross-Origin Resource Sharing = กฎความปลอดภัยของเบราว์เซอร์เรื่องเรียกข้าม origin (ออ-ริ-จิน)) — เบราว์เซอร์บล็อก React (localhost:5173) ไม่ให้เรียก API (localhost:8080) เพราะ same-origin policy (เซม-ออ-ริ-จิน พอล-ลิ-ซี่ = กฎ "เรียกได้เฉพาะ origin เดียวกัน") มาดูว่าทำไมเกิดและแก้ยังไง:

ทำ endpoint ง่าย ๆ ใน Spring Boot:

java
@RestController
@RequestMapping("/api")
public class HelloController {
    @GetMapping("/hello")
    public Map<String, String> hello() {
        return Map.of("message", "Hello from Spring Boot!");
    }
}

📌 annotation @RestController, @RequestMapping, @GetMapping ทำอะไร? เป็น "ป้าย" ที่บอก Spring ว่าให้จัดการคลาส/method นี้ยังไง — @RestController = คลาสนี้เป็น API ที่คืน JSON, @RequestMapping("/api") = ทุก endpoint ในคลาสนี้อยู่ใต้ path /api, @GetMapping("/hello") = method นี้ตอบ HTTP GET ที่ /api/hello (ดูรายละเอียดในบท Spring Boot)

เรียกจาก React:

📌 recap React hook สั้น ๆ: useState<T>(initial) = สร้างตัวแปร state พร้อม setter (setData เปลี่ยนค่า → React render ใหม่), useEffect(fn, []) = รัน fn ครั้งเดียวตอน component mount (โหลดข้อมูล) — ดู React book ถ้ายังไม่คุ้น

tsx
function App() {
    const [data, setData] = useState<{message: string} | null>(null);
    
    useEffect(() => {
        fetch('http://localhost:8080/api/hello')
            .then(r => r.json())
            .then(setData);
    }, []);
    
    return <div>{data?.message}</div>;
}

เปิด browser → console (หน้าต่างแสดง log/error ใน DevTools ของเบราว์เซอร์) ขึ้น error:

Access to fetch at 'http://localhost:8080/api/hello' from origin 'http://localhost:5173' 
has been blocked by CORS policy

แปล: "การเรียก http://localhost:8080/api/hello จาก origin http://localhost:5173 ถูกบล็อกด้วยกฎ CORS" — คือเบราว์เซอร์ห้ามเรียกข้าม origin นั่นเอง


3. ทำไม CORS มี?

Browser block การ "อ่านคำตอบ" จาก request ที่ข้าม origin (ออ-ริ-จิน) เพื่อความปลอดภัย — กฎพื้นฐานนี้เรียกว่า same-origin policy (SOP)

Origin = protocol + host + port:

  • http://localhost:5173http://localhost:8080 (port ต่าง)
  • https://app.comhttp://app.com (protocol ต่าง)
  • https://app.comhttps://api.app.com (host ต่าง)

SOP ป้องกันอะไร? ป้องกันไม่ให้ JavaScript จากเว็บ A อ่านข้อมูลจากเว็บ B (เช่น DOM, response body, cookie) — ถ้าไม่มี SOP เว็บร้ายจะดูดข้อมูลส่วนตัวจากเว็บที่คุณ login ไว้ได้

CORS คืออะไร (ให้ชัด): CORS ไม่ใช่ระบบรักษาความปลอดภัยเพิ่มCORS คือ "กลไกผ่อนปรน SOP อย่างมีการควบคุม" ที่ server บอก browser ว่า "origin ไหนได้รับอนุญาตให้อ่านคำตอบของฉันได้บ้าง" ผ่าน header Access-Control-Allow-Origin

⚠️ เข้าใจผิดที่พบบ่อย: "CORS = ป้องกัน CSRF" ผิด! CORS กับ CSRF เป็นคนละเรื่องกัน

  • CORS ควบคุม "ใครอ่านคำตอบ API ได้บ้าง" จากฝั่ง browser → ป้องกันการ "อ่านข้อมูล" ข้าม origin
  • CSRF (อ่านว่า "ซี-เซิร์ฟ" หรือ "ซี-เอส-อาร์-เอฟ" — Cross-Site Request Forgery (ครอส-ไซต์ รี-เควสท์ ฟ๊อจ-เจอ-รี่)) = การที่เว็บร้ายหลอกให้เบราว์เซอร์ของคุณ "ยิง request" (เช่น โอนเงิน) ไปเว็บที่คุณ login ไว้ โดยใช้ cookie ของคุณ — ป้องกันด้วย CSRF token (server ออก token ลับ ตรวจทุก request) หรือ SameSite cookie (SameSite=Lax/Strict ทำให้เบราว์เซอร์ไม่ส่ง cookie ข้าม site)

ที่จริง CORS ที่ตั้งหลวมเกิน (เช่น Access-Control-Allow-Origin: * กับ allowCredentials: true) จะ เปิดช่อง CSRF เพิ่ม ไม่ได้ป้องกัน — กลับกัน

ตัวอย่าง CSRF: คุณ login ที่ bank.com → มี cookie (คุกกี้ = ข้อมูลเล็ก ๆ ที่เบราว์เซอร์เก็บไว้ระบุว่าคุณ login อยู่) → คุณเปิด evil.com → ฟอร์มใน evil submit ไป bank.com/transfer พร้อม cookie ของคุณ → ป้องกันด้วย CSRF token + SameSite cookie ไม่ใช่ CORS

CORS preflight (พรี-ไฟลต์) — request ลับก่อนยิงจริง

สำหรับ request ที่ "ไม่ธรรมดา" (method ที่ไม่ใช่ GET/HEAD/POST, มี custom header, หรือ Content-Type ที่ไม่ใช่ form/text) browser จะส่ง preflight request เป็น OPTIONS ไปก่อนเพื่อ "ขออนุญาต":

# Browser ส่ง preflight ก่อน
OPTIONS /api/users HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

# Server ตอบกลับว่า "อนุญาต"
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: POST, GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600

# พอ preflight ผ่าน browser ค่อยยิง request จริง
POST /api/users HTTP/1.1
...

ถ้า preflight ไม่ผ่าน (เช่น server ตอบ 401/403 หรือไม่มี header CORS) → browser จะ ไม่ยิง request จริง เลย และโชว์ error ใน console


4. ทางแก้ที่ 1 — Spring Boot CORS Config

java
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("http://localhost:5173")     // dev
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")  // ไม่ต้องใส่ OPTIONS — Spring จัดการ preflight ให้เอง
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600);    // cache preflight 1 ชม. — แต่ Chrome cap ที่ ~2 ชม., Firefox ~24 ชม.
    }
}

หรือใน controller เดี่ยว ๆ:

java
@RestController
@CrossOrigin(origins = "http://localhost:5173")
public class HelloController { ... }

⚠️ อย่า ใช้ allowedOrigins("*") + allowCredentials(true) พร้อมกัน — Spring จะ throw ตอน startup
ใช้ allowedOriginPatterns("*") ถ้าต้องการ wildcard กับ credentials

🔴 ถ้าคุณใช้ Spring Security ด้วย — สำคัญมาก: WebMvcConfigurer อย่างเดียว ไม่พอ เพราะ Spring Security filter chain จะ block preflight OPTIONS ด้วย 401 ก่อนที่ CORS handler จะได้ทำงาน ต้องเปิด CORS ใน SecurityFilterChain ด้วย:

java
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
    http
        .cors(Customizer.withDefaults())   // ⭐ บอก Security ให้ใช้ CORS config ที่มี
        .csrf(csrf -> csrf.disable())
        // ... rest of config
        ;
    return http.build();
}

ถ้าไม่ใส่ .cors(...) จะเจอ preflight 401 และ frontend จะโชว์ CORS error ทั้งที่ config CORS ไว้แล้ว — เป็นกับดักที่เจอบ่อยมาก

⚠️ allowCredentials(true) + cookie — ต้องระวัง: ถ้าใช้ cookie auth ข้าม origin ใน production cookie ต้องตั้ง SameSite=None; Secure (HTTPS only) ไม่งั้นเบราว์เซอร์ไม่ส่ง cookie ให้ และบางเบราว์เซอร์ (โดยเฉพาะ Safari) จะมีพฤติกรรมพิเศษกับ http://localhost — ทดสอบกับเบราว์เซอร์จริงทุกตัวก่อน deploy


ดีกว่า — React ไม่รู้ ว่า backend อยู่ที่ไหน:

📌 vite.config.ts มาจากไหน? Vite สร้างไฟล์นี้ให้ตอน npm create vite@latest แล้ว — เปิดมาก็มีอยู่แล้วในรูทของโปรเจกต์ frontend แค่เพิ่ม server.proxy เข้าไป

ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [react()],
    server: {
        proxy: {
            '/api': {
                target: 'http://localhost:8080',
                changeOrigin: true,
                // secure: false,                  // ถ้า upstream เป็น HTTPS แต่ self-signed cert
                // ws: true,                        // ถ้า backend มี WebSocket (เช่น STOMP)
                // rewrite: (p) => p.replace(/^\/api/, ''),  // ถ้า backend ไม่ได้ขึ้นต้นด้วย /api
            }
        }
    }
});

เรียกใน React:

tsx
fetch('/api/hello')          // ⭐ no host

Vite dev server intercept /api/* → proxy ไป localhost:8080
Browser มอง = same origin → ไม่มี CORS

ข้อดี

  • ✅ Same origin = ไม่มี CORS issue
  • ✅ Backend config ไม่ต้องเปลี่ยน
  • ✅ Code เดียวกัน dev + production (production ใช้ nginx proxy)
  • ✅ Cookie ส่งได้

⚠️ ข้อจำกัด

  • ใช้ได้แค่ dev — production ต้องใช้ nginx (บทที่ 5)

6. CORS ใน Production

CORS ใน dev แก้ด้วย proxy ได้ แต่ production ต่างออกไป — ทางเลือกที่ดีที่สุดคือเสิร์ฟ frontend + API ภายใต้ domain เดียวผ่าน nginx (same origin = ไม่มี CORS) ส่วนถ้าแยก subdomain ก็ต้องตั้ง CORS ให้รัดกุม (allowedOrigins เฉพาะ domain จริง):

Frontend deploy บน https://app.comAPI ที่ https://api.app.com

Option A: Same domain ผ่าน nginx (recommended)

https://app.com/         → static React
https://app.com/api/*    → proxy → Spring Boot

→ Same origin → no CORS

Option B: Different subdomain + CORS

java
@Profile("prod")
@Configuration
public class ProdCorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://app.com")
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")  // ⭐ ระบุชัด ไม่ใช้ "*"
            .allowedHeaders("Authorization", "Content-Type")
            .allowCredentials(true)
            .maxAge(3600);
    }
}

⚠️ ห้ามใช้ allowedMethods("*") กับ allowCredentials(true) — ตั้งแต่ Spring 5.3 จะ throw ตอน startup เลย (ไม่ใช่ runtime) เพราะ spec CORS ห้าม wildcard กับ credentials ระบุ method ที่ใช้จริงเสมอ — และยังเป็น good security hygiene


Part 2: API Layer Pattern

7. ปัญหา — fetch กระจาย

tsx
// ❌ fetch กระจายทุก component
function UserList() {
    useEffect(() => {
        fetch('http://localhost:8080/api/users')
            .then(r => r.json())
            .then(setUsers);
    }, []);
}

function UserDetail() {
    useEffect(() => {
        fetch(`http://localhost:8080/api/users/${id}`)
            .then(r => r.json())
            .then(setUser);
    }, []);
}

ปัญหา:

  • เปลี่ยน baseURL → แก้ทุกที่
  • ลืมใส่ Auth header
  • Error handling ซ้ำ
  • ไม่ type-safe

ทางแก้: API layer ที่รวมศูนย์


8. โครงสร้าง API Layer

แทนที่จะ fetch กระจายทุก component (แก้ baseURL ทีต้องไล่แก้ทุกที่) เรารวมการเรียก API ไว้ใน "API layer" — แยกเป็น client (wrapper), endpoint module ต่อ resource และ hook layer การจัดโครงแบบนี้ทำให้ auth/error handling อยู่ที่เดียวและ type-safe ทั้งระบบ:

src/
├── api/
│   ├── client.ts          ← fetch wrapper (auth, error, baseURL)
│   ├── types.ts           ← shared types
│   ├── users.ts           ← user endpoints
│   ├── orders.ts          ← order endpoints
│   └── auth.ts            ← auth endpoints
├── features/
│   └── users/
│       └── hooks.ts       ← useUsers, useUser (TanStack Query — ดูคำอธิบายข้างล่าง)
└── ...

TanStack Query (ชื่อเดิม React Query) = ไลบรารีจัดการ "ข้อมูลที่ดึงมาจากเซิร์ฟเวอร์" ใน React ให้อัตโนมัติ — มันจัดการ cache (เก็บข้อมูลที่โหลดแล้วไว้ใช้ซ้ำ), สถานะ loading/error, และโหลดใหม่ให้เอง ทำให้ component ไม่ต้องเขียน useEffect + useState ดึงข้อมูลเองทุกที่ (เจอเต็ม ๆ ที่ §11)


9. HTTP Client (using fetch)

ชั้นล่างสุดของ API layer คือ HTTP client — wrapper (ตัวห่อ = ฟังก์ชันที่ครอบของเดิมเพื่อเพิ่มงานให้) รอบ fetch (ฟังก์ชันมาตรฐานของเบราว์เซอร์ไว้ยิง HTTP request) ที่ใส่ baseURL (ที่อยู่ตั้งต้นของ API), แนบ auth token, แปลง error เป็นรูปแบบเดียวกัน และ parse (แปลงข้อความเป็นอ็อบเจกต์) JSON ให้ ทุก endpoint จะเรียกผ่าน client ตัวนี้ จึงจัดการ cross-cutting concern (ครอส-คัท-ติ้ง คอน-เซิร์น = เรื่องที่ตัดผ่านทุกที่ ต้องทำซ้ำในทุก endpoint เช่น auth/error/log) ที่เดียว

📌 recap TypeScript/JavaScript ที่จะใช้ในตัวอย่าง:

  • Promise<T> = อ็อบเจกต์ที่บอกว่า "ผลลัพธ์จะมาทีหลัง" และจะเป็น type T (เช่น Promise<User> = "อีกสักพักจะได้ User")
  • async/await = syntax sugar สำหรับ Promise — async function คืน Promise, await x รอ Promise x เสร็จแล้วเอาค่าออกมา
  • generic <T> = "ตัวแปร type" บอกว่าฟังก์ชันรับ/คืน type อะไรก็ได้ — request<User>(...) = บอกว่า "ฟังก์ชันนี้คืน User"
  • class ที่ extends Error = ทำคลาส error เอง เพิ่ม field พิเศษ (เช่น status, code) เพื่อ frontend แยกแยะ error ได้ละเอียดกว่า message อย่างเดียว

🔜 โค้ดข้างล่างพูดถึง useAuthStore (ที่เก็บ token กลางของแอป สร้างด้วยไลบรารี Zustand = ไลบรารีจัดเก็บ state ส่วนกลางของ React แบบเบา ๆ) — ยังไม่ต้องสนใจรายละเอียดตอนนี้ เดี๋ยวสร้างเต็ม ๆ ใน บทที่ 2 §5

Step 1 — Client พื้นฐาน (ยังไม่มี auth)

เริ่มจากเวอร์ชันที่ไม่มี auth ก่อน เพื่อเห็นโครงสร้างชัด ๆ:

ts
// src/api/client.ts
const API_BASE = '/api';     // ที่อยู่ตั้งต้น — ใช้ Vite proxy ตอน dev (ดู §5)

export class ApiError extends Error {
    constructor(
        public status: number,
        public code: string,
        message: string,
        public details?: unknown
    ) {
        super(message);
    }
}

interface ApiErrorBody {
    error: { code: string; message: string; details?: unknown };
}

// ⭐ ตัวเลือกที่ปรับได้ตอนเริ่มแอป — แยก client ออกจาก UI/router
interface ClientOptions {
    getToken?: () => string | null;          // ดึง token (callback)
    onUnauthorized?: () => void;              // ถูกเรียกตอน 401 (เช่น logout, redirect)
}

let opts: ClientOptions = {};

export function configureClient(options: ClientOptions) {
    opts = options;
}

async function request<T>(
    path: string,
    options: RequestInit = {}
): Promise<T> {
    const token = opts.getToken?.();
    const hasBody = options.body !== undefined && options.body !== null;
    
    const res = await fetch(`${API_BASE}${path}`, {
        ...options,
        headers: {
            // ⭐ ใส่ Content-Type เฉพาะตอนมี body จริง (GET/DELETE ไม่ต้องมี)
            ...(hasBody && { 'Content-Type': 'application/json' }),
            ...(token && { Authorization: `Bearer ${token}` }),
            ...options.headers,
        },
    });
    
    if (!res.ok) {
        let body: ApiErrorBody | null = null;
        try { body = await res.json(); } catch { /* ignore */ }
        
        // 401 → แจ้งผ่าน callback ไม่เรียก store/router ตรง ๆ
        if (res.status === 401) {
            opts.onUnauthorized?.();
        }
        
        throw new ApiError(
            res.status,
            body?.error?.code ?? 'UNKNOWN',
            body?.error?.message ?? res.statusText,
            body?.error?.details
        );
    }
    
    if (res.status === 204) return undefined as T;     // 204 No Content = สำเร็จแต่ไม่มี body
    return res.json();
}

export const http = {
    get: <T>(path: string) => 
        request<T>(path),
    
    // ⭐ ทุก method ต้องกัน undefined body ก่อน stringify
    // (JSON.stringify(undefined) = undefined string literal → fetch ตีความผิด)
    post: <T>(path: string, body?: unknown) =>
        request<T>(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
    
    put: <T>(path: string, body?: unknown) =>
        request<T>(path, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }),
    
    patch: <T>(path: string, body?: unknown) =>
        request<T>(path, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
    
    delete: <T>(path: string) =>
        request<T>(path, { method: 'DELETE' }),
};

Step 2 — ผูกกับ auth store ตอน bootstrap

ที่จุดเริ่มแอป (เช่น main.tsx) เรียก configureClient ครั้งเดียว ให้ HTTP client รู้จักวิธีดึง token และจัดการ 401 — โดย HTTP client ไม่ต้อง import store ตรง ๆ (decouple):

ts
// src/main.tsx
import { configureClient } from '@/api/client';
import { useAuthStore } from '@/stores/auth';
import { router } from '@/router';

configureClient({
    getToken: () => useAuthStore.getState().accessToken,
    onUnauthorized: () => {
        useAuthStore.getState().logout();
        router.navigate('/login?reason=session_expired');
    },
});

🟢 ทำไมต้องแยกแบบนี้? ถ้า client.ts import useAuthStore ตรง ๆ จะกลายเป็นว่า API layer "รู้จัก" UI layer (store + router) — ทดสอบยาก, reuse ข้ามแอปไม่ได้, dependency เป็นวงกลม การให้ผูกผ่าน callback ตอน bootstrap ทำให้ client เป็นแค่ "ตัวยิง HTTP" บริสุทธิ์

⚠️ เรื่อง race ระหว่าง redirect กับ error toast: เมื่อเกิด 401 → onUnauthorized redirect ไปหน้า login, แต่ throw new ApiError(...) ยัง bubble ไปถึง MutationCache.onError ทำให้โชว์ toast "ซ้อน" ระหว่างเปลี่ยนหน้า — แก้ได้โดย global error handler เช็ค error.status === 401 แล้วไม่โชว์ toast (ดู §15)


10. Endpoint Module

เหนือ HTTP client ขึ้นมาคือ endpoint module ที่แยกตาม resource (users, orders) — รวม type และฟังก์ชันเรียก API ของ resource นั้นไว้ที่เดียว ทำให้ component เรียก usersApi.list() แบบ type-safe โดยไม่ต้องรู้ path/method:

ts
// src/api/types.ts
export interface User {
    id: number;
    email: string;
    name: string;
    role: 'USER' | 'ADMIN';
    createdAt: string;
}

export interface CreateUserRequest {
    email: string;
    name: string;
    password: string;
}

export interface PageResult<T> {
    content: T[];
    totalElements: number;
    totalPages: number;
    page: number;
    size: number;
}
ts
// src/api/users.ts
import { http } from './client';
import type { User, CreateUserRequest, PageResult } from './types';

export const usersApi = {
    // signal เป็น optional — TanStack Query v5 ส่งมาให้เพื่อ cancel ได้
    list: (params?: { page?: number; size?: number; search?: string }, signal?: AbortSignal) => {
        const qs = new URLSearchParams();
        if (params?.page !== undefined) qs.set('page', String(params.page));
        if (params?.size !== undefined) qs.set('size', String(params.size));
        if (params?.search) qs.set('search', params.search);
        const query = qs.toString() ? `?${qs}` : '';
        return http.get<PageResult<User>>(`/users${query}`, signal);
    },
    
    get: (id: number, signal?: AbortSignal) => http.get<User>(`/users/${id}`, signal),
    
    create: (data: CreateUserRequest) => http.post<User>('/users', data),
    
    update: (id: number, data: Partial<CreateUserRequest>) => 
        http.patch<User>(`/users/${id}`, data),
    
    delete: (id: number) => http.delete<void>(`/users/${id}`),
};

📌 http.get รับ signal ได้ด้วยเหรอ? ตัวอย่าง client ใน §9 รับเฉพาะ path เพื่อให้สั้น — ในการใช้จริงเพิ่ม parameter signal?: AbortSignal แล้วส่งเข้า fetch(..., { signal }) เพื่อให้ TanStack Query cancel request ที่ค้างได้


11. Hook Layer (TanStack Query v5)

ชั้นบนสุดคือ hook layer ที่ห่อ endpoint ด้วย TanStack Query (เวอร์ชัน 5+) — ได้ caching, refetch, loading/error state และ cache invalidation อัตโนมัติ component แค่เรียก useUsers() ก็ได้ข้อมูล + สถานะครบ ไม่ต้องจัดการ useEffect/useState เอง สังเกต query key pattern ที่ทำให้ invalidate ตรงจุด:

📌 ทำไมต้องมี query key pattern แบบ hierarchy? queryKey ของ TanStack Query เป็น array ที่ใช้แยกแยะ cache แต่ละชุด การจัด pattern เป็น alllists()list(params)detail(id) ทำให้ "invalidate ระดับไหนก็ได้": ยิง invalidateQueries({ queryKey: userKeys.lists() }) = ลบ cache ของ list ทุกชุดทีเดียว (ทุก search/page) โดยไม่กระทบ detail — ถ้าจัด key มั่ว ๆ จะต้องไล่ลบทีละอันหรือเผลอ invalidate ทั้งหมด

📌 TanStack Query v5 ที่เปลี่ยนจาก v4 (กันสับสน):

  • queryKey ต้องเป็น array เสมอ (ใน v3 เคยใช้ string ได้)
  • cacheTime เปลี่ยนชื่อเป็น gcTime (garbage collection time)
  • มี signal ใน queryFn สำหรับ AbortController ในตัว
  • useQuery({ queryKey, queryFn, ... }) รับ object เท่านั้น (ไม่ใช่ positional)
ts
// src/features/users/hooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi } from '@/api/users';

interface UserListParams {
    page?: number;
    size?: number;
    search?: string;
}

export const userKeys = {
    all: ['users'] as const,
    lists: () => [...userKeys.all, 'list'] as const,
    list: (params?: Partial<UserListParams>) => [...userKeys.lists(), params ?? {}] as const,
    details: () => [...userKeys.all, 'detail'] as const,
    detail: (id: number) => [...userKeys.details(), id] as const,
};

export function useUsers(params?: UserListParams) {
    return useQuery({
        queryKey: userKeys.list(params),
        // ⭐ v5: รับ signal เพื่อ cancel ตอน unmount หรือ key เปลี่ยน
        queryFn: ({ signal }) => usersApi.list(params, signal),
        staleTime: 30_000,            // ข้อมูล "สด" 30 วิ — ไม่ refetch ทันที
        gcTime: 5 * 60_000,           // ⭐ v5: cache อยู่ใน memory 5 นาทีหลัง unmount (เดิมชื่อ cacheTime)
    });
}

export function useUser(id: number) {
    return useQuery({
        queryKey: userKeys.detail(id),
        queryFn: ({ signal }) => usersApi.get(id, signal),
        enabled: !!id,
    });
}

export function useCreateUser() {
    const qc = useQueryClient();
    return useMutation({
        mutationFn: usersApi.create,
        // ⭐ invalidate ทั้ง list หลัง create สำเร็จ — TanStack refetch ให้เอง
        onSuccess: () => {
            qc.invalidateQueries({ queryKey: userKeys.lists() });
        },
    });
}

export function useDeleteUser() {
    const qc = useQueryClient();
    return useMutation({
        mutationFn: usersApi.delete,
        onSuccess: () => {
            qc.invalidateQueries({ queryKey: userKeys.lists() });
        },
    });
}

🟢 cursor pagination (advanced): ตัวอย่างนี้ใช้ page/size ที่เข้าใจง่าย แต่สำหรับ dataset ใหญ่หรือ infinite scroll ควรใช้ cursor pagination (backend คืน nextCursor token แทน totalPages) คู่กับ useInfiniteQuery — เร็วและ stable กว่าตอนข้อมูลถูกแทรก/ลบระหว่างหน้า (รายละเอียดอยู่ในบทขั้นสูง)


12. ใช้ใน Component

เมื่อมี hook layer แล้ว component สะอาดมาก — เรียก useUsers() ได้ data + isLoading + error พร้อมใช้ และ mutation hook สำหรับ create/delete ที่ invalidate cache ให้เอง ตัวอย่างนี้แสดงว่า component โฟกัสแค่ UI ไม่ต้องยุ่งกับ data fetching logic:

tsx
function UserList() {
    const [search, setSearch] = useState('');
    const { data, isLoading, error } = useUsers({ search });
    const deleteUser = useDeleteUser();
    
    if (isLoading) return <Spinner />;
    if (error) return <ErrorMessage error={error} />;
    
    return (
        <div>
            <input
                value={search}
                onChange={(e) => setSearch(e.target.value)}
                placeholder="Search..."
            />
            
            <ul>
                {data?.content.map((u) => (
                    <li key={u.id}>
                        {u.name} ({u.email})
                        <button onClick={() => deleteUser.mutate(u.id)}>
                            Delete
                        </button>
                    </li>
                ))}
            </ul>
        </div>
    );
}

ทุก layer ทำหน้าที่ตัวเอง:

  • Component → UI
  • Hook → query/mutation state + cache
  • API moduleHTTP call ที่ type-safe
  • Client → auth, error, baseURL

Part 3: Error Handling — End-to-End

13. ตกลง Error Format

error handling ที่ดีต้องเริ่มจาก "ตกลง format กลาง" ระหว่าง backend กับ frontend — backend คืน error ที่มีโครงเดียวกันเสมอ (code/message/details) ผ่าน @RestControllerAdvice แล้ว frontend จึง parse และแสดงผลได้สม่ำเสมอ ส่วนนี้เริ่มที่ฝั่ง backend:

Backend (Spring Boot):

java
public record ErrorResponse(
    Error error,
    Instant timestamp
) {
    public record Error(String code, String message, List<FieldError> details) {}
    public record FieldError(String field, String message) {}
}

📌 สังเกตในโค้ดข้างล่างมีการเรียก log.error(...) — ตัวแปร log มาจาก annotation @Slf4j ของ Lombok (ดูคำอธิบาย Lombok ที่ §1) ถ้าลืมใส่ @Slf4j จะ compile ไม่ผ่าน

📌 annotation Spring ที่ใช้ในโค้ดข้างล่าง: @RestControllerAdvice = "interceptor" จับ exception ทุก controller, @ExceptionHandler(X.class) = method นี้รับมือ exception ชนิด X, ResponseEntity<T> = wrapper ที่ระบุ status code + headers + body ได้ — รายละเอียดเต็มอยู่ใน บท Spring MVC Details

java
@Slf4j                          // Lombok สร้างตัวแปร log ให้ (ไว้พิมพ์ลง log)
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
            new ErrorResponse(
                new ErrorResponse.Error("NOT_FOUND", e.getMessage(), null),
                Instant.now()
            )
        );
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        List<ErrorResponse.FieldError> errors = e.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(fe -> new ErrorResponse.FieldError(fe.getField(), fe.getDefaultMessage()))
            .toList();
        
        return ResponseEntity.badRequest().body(
            new ErrorResponse(
                new ErrorResponse.Error("VALIDATION_ERROR", "Validation failed", errors),
                Instant.now()
            )
        );
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleConflict(DataIntegrityViolationException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(
            new ErrorResponse(
                new ErrorResponse.Error("CONFLICT", "Resource already exists", null),
                Instant.now()
            )
        );
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception e) {
        log.error("Unhandled exception", e);
        return ResponseEntity.internalServerError().body(
            new ErrorResponse(
                new ErrorResponse.Error("INTERNAL_ERROR", "Something went wrong", null),
                Instant.now()
            )
        );
    }
}

14. Frontend Error Handler

เมื่อ backend คืน error format มาตรฐานแล้ว frontend จัดการได้ครบ — แยกแสดงผลตาม code (validation error โชว์ราย field, error อื่นโชว์ message) component ErrorMessage ตัวเดียวรองรับทุกเคส ทำให้ UX การแสดง error สม่ำเสมอทั้งแอป:

📌 ใช้ Tailwind CSS — class แบบ bg-red-50 border border-red-200 คือ utility class ของ Tailwind CSS (ระบบ styling แบบเขียน class สำเร็จลงใน HTML) ถ้ายังไม่ติดตั้งให้ดู tailwindcss.com/docs/installation หรือเปลี่ยนเป็น CSS/style inline ตามใจชอบ — ส่วนสำคัญคือโครง component ไม่ใช่ตัว CSS

tsx
// src/components/ErrorMessage.tsx
import { ApiError } from '@/api/client';

interface Props { error: unknown }

export function ErrorMessage({ error }: Props) {
    if (error instanceof ApiError) {
        if (error.code === 'VALIDATION_ERROR' && Array.isArray(error.details)) {
            return (
                <div className="bg-red-50 border border-red-200 p-3 rounded">
                    <p className="text-red-800 font-medium">Validation failed</p>
                    <ul className="text-sm text-red-700 mt-2 list-disc list-inside">
                        {(error.details as { field: string; message: string }[]).map((d, i) => (
                            <li key={i}>{d.field}: {d.message}</li>
                        ))}
                    </ul>
                </div>
            );
        }
        return (
            <div className="bg-red-50 border border-red-200 p-3 rounded text-red-800">
                {error.message}
            </div>
        );
    }
    
    return (
        <div className="bg-red-50 border border-red-200 p-3 rounded text-red-800">
            Unexpected error: {(error as Error).message}
        </div>
    );
}

15. Toast on Mutation Error

แทนที่จะ handle error ของ mutation ทุกที่ TanStack Query มี global MutationCache ที่ดักทุก error มาแสดง toast ที่เดียว — user เห็นข้อความ error ทันทีโดยที่ developer ไม่ต้องเขียน error handling ซ้ำในทุก mutation:

tsx
// src/api/queryClient.ts
import { QueryCache, MutationCache, QueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';                  // หรือ react-hot-toast
import { ApiError } from './client';

export const queryClient = new QueryClient({
    queryCache: new QueryCache({
        onError: (error) => {
            if (error instanceof ApiError && error.status !== 401) {
                toast.error(error.message);
            }
        },
    }),
    mutationCache: new MutationCache({
        onError: (error) => {
            if (error instanceof ApiError) {
                toast.error(error.message);
            }
        },
    }),
});

Part 4: Environment Variables

16. Vite Environment

Vite environment variable ต้องขึ้นต้นด้วย VITE_:

# .env.development
VITE_API_URL=http://localhost:8080/api
VITE_APP_NAME=My App (Dev)

# .env.production
VITE_API_URL=/api
VITE_APP_NAME=My App
ts
// src/config.ts
export const config = {
    apiUrl: import.meta.env.VITE_API_URL,
    appName: import.meta.env.VITE_APP_NAME,
    isDev: import.meta.env.DEV,
    isProd: import.meta.env.PROD,
};

⚠️ ไม่มี secret ใน .env ของ frontend — bundle ลง browser ทุกคนเห็น


17. TypeScript สำหรับ env

import.meta.env.VITE_* เป็น string แบบ any โดย default — ทำให้พิมพ์ชื่อผิดก็ไม่ error ประกาศ type ใน vite-env.d.ts ทำให้ env variable เป็น type-safe (autocomplete + error ถ้าพิมพ์ผิด) เป็น good practice เล็ก ๆ ที่กันบั๊กได้:

ts
// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
    readonly VITE_API_URL: string;
    readonly VITE_APP_NAME: string;
}

interface ImportMeta {
    readonly env: ImportMetaEnv;
}

18. Spring Boot Profiles

ฝั่ง backend จัดการ config ต่าง environment ด้วย "profile" — application-dev.yml, application-prod.yml แยกค่า (DB, CORS, log level) แล้วเลือก profile ตอนรันด้วย SPRING_PROFILES_ACTIVE ทำให้ code เดียวรันได้ทุก env โดยไม่ต้องแก้:

yaml
# application.yml (base)
spring:
  application:
    name: backend
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:dev}

server:
  port: 8080

---
# application-dev.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/appdb
    username: app
    password: secret

cors:
  origins: http://localhost:5173

logging:
  level:
    com.example: DEBUG

---
# application-prod.yml
spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USER}
    password: ${DATABASE_PASSWORD}

cors:
  origins: ${CORS_ORIGINS}

logging:
  level:
    com.example: INFO
bash
# dev
./mvnw spring-boot:run
# → uses application-dev.yml

# prod
java -jar -Dspring.profiles.active=prod app.jar
# หรือ
SPRING_PROFILES_ACTIVE=prod java -jar app.jar

19. Inject Config

แทนที่จะอ่าน config ด้วย @Value กระจัดกระจาย Spring มี @ConfigurationProperties ที่ map config block เป็น typed object (record) แล้ว inject ไปใช้ — สะอาด, type-safe และ validate ได้ ตัวอย่างนี้ผูก CORS origins จาก profile เข้ากับ config:

📌 Java record คืออะไร? record (เพิ่มมาใน Java 14+) คือคลาสที่ "เก็บค่าอย่างเดียว" (immutable data carrier) — เขียน public record CorsProperties(List<String> origins) {} บรรทัดเดียวจะได้ field origins, constructor, getter origins(), equals, hashCode, toString ครบ ใช้แทน DTO/value object สั้น ๆ ดีมาก

java
@ConfigurationProperties("cors")
public record CorsProperties(List<String> origins) {}

@Configuration
@EnableConfigurationProperties(CorsProperties.class)
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    private final CorsProperties props;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins(props.origins().toArray(String[]::new))
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")  // ⭐ ระบุชัด ห้ามใช้ "*" กับ credentials
            .allowedHeaders("Authorization", "Content-Type")
            .allowCredentials(true)
            .maxAge(3600);
    }
}

⚠️ ห้ามใช้ allowedMethods("*") กับ allowCredentials(true) Spring 5.3+ จะ throw IllegalArgumentException ตอน startup — ใช้ list method ที่ชัดเจน หรือถ้าจำเป็นต้อง wildcard ให้ใช้ allowedOriginPatterns("*") (สำหรับ origins) คู่กับ list method จริง


Part 5: Loading + Error UX

20. Skeleton Loading

loading UX ที่ดีไม่ใช่แค่ spinner หมุน ๆ — skeleton (โครงสีเทาที่คล้ายเนื้อหาจริง) ทำให้ผู้ใช้รู้สึกว่าโหลดเร็วและเห็นว่ากำลังจะมีอะไรขึ้นมา TanStack Query ให้ isLoading ใช้สลับ skeleton ↔ ข้อมูลจริงได้ง่าย:

tsx
function UserListSkeleton() {
    return (
        <ul className="space-y-2">
            {[...Array(5)].map((_, i) => (
                <li key={i} className="h-12 bg-gray-200 animate-pulse rounded" />
            ))}
        </ul>
    );
}

function UserList() {
    const { data, isLoading, error } = useUsers();
    
    if (isLoading) return <UserListSkeleton />;
    if (error) return <ErrorMessage error={error} />;
    
    return <ul>{data?.content.map(u => <li>{u.name}</li>)}</ul>;
}

21. Retry กับ Optimistic

🚀 โซนขั้นสูง — ข้ามได้ทั้งหัวข้อ "Optimistic update" (อัปเดต UI ทันทีก่อนเซิร์ฟเวอร์ตอบ แล้วถ้า fail ค่อยย้อนกลับ) เป็นเทคนิคทำให้แอปลื่น แต่ค่อนข้างซับซ้อน (มี onMutate, snapshot, rollback, race condition) — รอบแรกข้ามทั้งหัวข้อนี้ไปได้เลย เดี๋ยวบทที่ 3 จะอธิบาย optimistic แบบละเอียดทีละขั้น (บทที่ 3 §10–15)

tsx
export function useDeleteUser() {
    const qc = useQueryClient();
    return useMutation({
        mutationFn: usersApi.delete,
        
        // Optimistic
        onMutate: async (id) => {
            await qc.cancelQueries({ queryKey: userKeys.lists() });
            const previous = qc.getQueriesData({ queryKey: userKeys.lists() });
            
            qc.setQueriesData({ queryKey: userKeys.lists() }, (old: any) => ({
                ...old,
                content: old.content.filter((u: User) => u.id !== id),
            }));
            
            return { previous };
        },
        
        onError: (err, id, ctx) => {
            // Rollback
            if (ctx?.previous) {
                ctx.previous.forEach(([key, data]) => qc.setQueryData(key, data));
            }
        },
        
        onSettled: () => {
            qc.invalidateQueries({ queryKey: userKeys.lists() });
        },
    });
}

Part 6: ⚠️ Common Pitfalls

Pitfallแก้
CORS error → ใส่ * ทุกที่ใช้ Vite proxy (dev) + same domain via nginx (prod)
Hardcode http://localhost:8080ใช้ env variable + Vite proxy
API client repeat ทุก componentใช้ API layer pattern
Token หลุดใน console.loglog แค่ method + URL — อย่า log header
Forget error handling — silent failglobal onError ใน QueryClient
Type ต่างฝั่ง — Java DTO กับ TS interface ไม่ตรงใช้ tools generate (openapi-typescript, springdoc + openapi-generator)
Date เป็น string ใน JSONparse กลับเป็น Date ใน frontend หรือใช้ Zod transform

22. Auto-generate Type จาก OpenAPI

🚀 โซนขั้นสูง — รอบแรกข้ามได้สบาย ๆ หัวข้อนี้เป็นลูกเล่นเพิ่มความสะดวก (ให้เครื่องสร้าง TypeScript type ให้อัตโนมัติ) มือใหม่ "เขียน type เองด้วยมือ" ตาม §10 ไปก่อนได้ ค่อยกลับมาทำตอนโปรเจกต์ใหญ่ขึ้นและ DTO เปลี่ยนบ่อย — ไม่ต้องเข้าใจ syntax ยาว ๆ ของ paths[...] ในตอนนี้

OpenAPI = มาตรฐานกลางที่ใช้ "อธิบายหน้าตา API" (มี endpoint อะไร รับ-ส่งอะไร) เป็นไฟล์ที่เครื่องอ่านได้ — พอมีไฟล์นี้ เครื่องมือก็ generate (สร้างอัตโนมัติ) TypeScript type ฝั่ง frontend ให้ตรงกับ backend ได้เลย

xml
<!-- backend/pom.xml -->
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
    <!-- เช็คเวอร์ชันล่าสุดที่ https://springdoc.org — 2.6.0 ออกปลายปี 2024 -->
</dependency>

Spring Boot จะ generate (สร้าง) OpenAPI spec (เอกสารอธิบาย API) ที่ /v3/api-docs

Frontend:

bash
npm install -D openapi-typescript
npx openapi-typescript http://localhost:8080/v3/api-docs -o src/api/schema.ts

ใช้:

ts
import type { paths } from './api/schema';

type UserResponse = paths['/api/users/{id}']['get']['responses']['200']['content']['application/json'];

บรรทัด paths[...][...]... ยาว ๆ นั้นอ่านยังไง? มันคือการ "ไล่เจาะเข้าไปทีละชั้น" ในไฟล์ schema ที่ generate มา อ่านจากซ้ายไปขวาทีละวงเล็บ:

  1. paths = รวมทุก endpoint ของ API
  2. ['/api/users/{id}'] = เลือก endpoint นี้ (path ดึง user ตาม id)
  3. ['get'] = เลือก HTTP method GET ของ endpoint นั้น
  4. ['responses'] = ดูส่วน "คำตอบที่เป็นไปได้"
  5. ['200'] = เลือกคำตอบกรณีสำเร็จ (status 200)
  6. ['content']['application/json'] = เอาเฉพาะเนื้อหาที่เป็น JSON → ได้ type ของ user ที่ส่งกลับมา

→ type ตรงกับ backend อัตโนมัติ + เปลี่ยน DTO ที่ Spring → re-generate (สร้างใหม่) ที่ React


23. Checkpoint

🛠️ Checkpoint 1.1 — Hello Stack
สร้าง:

  • Spring Boot endpoint GET /api/hello → return {message: "Hello"}
  • React app เรียก + แสดง
  • ตั้ง Vite proxy → ใช้ /api/hello ใน fetch

🛠️ Checkpoint 1.2 — Setup API Layer
ทำ:

  • client.ts ที่มี get/post/put/delete + auth header
  • users.ts ที่มี list/get/create/update/delete
  • useUsers, useUser, useCreateUser, useDeleteUser (TanStack Query)
  • หน้า /users ที่ใช้ hook นี้

🛠️ Checkpoint 1.3 — Error Handling
สร้าง endpoint Spring ที่ throw exception → ดู error format → จัดการใน frontend (toast + inline message)

🛠️ Checkpoint 1.4 — Generate Types
ติด springdoc-openapi + openapi-typescript → generate type → ใช้ใน API layer


24. Glossary — คำศัพท์ + วิธีอ่าน

ศัพท์ที่ใช้บ่อยในบทนี้ พร้อมคำอ่านสำหรับคนไทย:

คำอ่านว่าความหมายสั้น ๆ
CORSคอร์สCross-Origin Resource Sharing — กลไกที่ server บอก browser ว่า origin ไหนอ่านคำตอบได้
originออ-ริ-จินprotocol + host + port (เช่น http://localhost:5173)
same-origin policy (SOP)เซม-ออ-ริ-จิน พอล-ลิ-ซี่กฎพื้นฐานของ browser: JS อ่านข้อมูลข้าม origin ไม่ได้
preflightพรี-ไฟลต์request OPTIONS ที่ browser ส่งก่อน request จริงเพื่อ "ขออนุญาต" CORS
CSRFซี-เซิร์ฟ / ซี-เอส-อาร์-เอฟCross-Site Request Forgery (ครอส-ไซต์ รี-เควสท์ ฟ๊อจ-เจอ-รี่) — เว็บร้ายหลอกให้เบราว์เซอร์ส่ง request พร้อม cookie คุณ ป้องกันด้วย CSRF token / SameSite cookie (ไม่ใช่ CORS)
cross-cutting concernครอส-คัท-ติ้ง คอน-เซิร์นเรื่องที่ตัดผ่านทุกที่ (auth, logging, error) จัดการรวมศูนย์
wrapperแรพ-เปอร์ตัวห่อฟังก์ชันเดิมเพื่อเพิ่มงานก่อน/หลัง
proxyพร็อก-ซี่ตัวกลางส่งต่อ request — Vite proxy = dev server ส่งต่อให้ backend
tokenโท-เคนข้อความลับที่ใช้พิสูจน์ตัวตน (เช่น JWT)
cookieคุก-กี้ข้อมูลเล็ก ๆ ที่ browser เก็บให้ server (มักใช้ระบุ session)
interceptorอิน-เทอร์-เซพ-เตอร์ตัวดักกลางทาง — @RestControllerAdvice ดัก exception ทุก controller
invalidateอิน-วา-ลิ-เดทบอกว่า cache เก่าหมดอายุ ต้องโหลดใหม่
gcTimeจี-ซี-ไทม์TanStack Query v5: เวลาที่ cache อยู่ใน memory หลัง unmount (เดิมชื่อ cacheTime)
staleTimeสเตล-ไทม์เวลาที่ข้อมูลยัง "สด" — ยังไม่ refetch อัตโนมัติ

25. สรุปบท

CORS (คอร์ส) = กลไกผ่อนปรน Same-Origin Policy — ตั้ง Access-Control-Allow-Origin ที่ server (ไม่ใช่ระบบกัน CSRF)
CSRF เป็นคนละเรื่อง — ป้องกันด้วย CSRF token + SameSite cookie
preflight (OPTIONS) มาก่อน request "ไม่ธรรมดา" — Spring จัดการอัตโนมัติ ไม่ต้องใส่ OPTIONS ใน allowedMethods
✅ ใช้ Spring Security → ต้องเพิ่ม http.cors(Customizer.withDefaults()) ใน SecurityFilterChain ด้วย ไม่งั้น preflight = 401
✅ ห้าม allowedOrigins("*") หรือ allowedMethods("*") คู่กับ allowCredentials(true) — Spring throw ตอน startup
Dev → ใช้ Vite proxy (same origin, ไม่มี CORS issue)
Prod → ใช้ nginx reverse proxy (same domain)
API layer = client.ts + endpoint modules + TanStack Query v5 hooks (queryKey เป็น array, gcTime, signal)
HTTP client ไม่ import store ตรง ๆ — ผูกผ่าน callback ตอน bootstrap
✅ กัน JSON.stringify(undefined) ทุก method ที่มี body (body !== undefined ? JSON.stringify(body) : undefined)
✅ Error format consistent ทั้ง stack: { error: { code, message, details }, timestamp }
✅ Global error handler (Spring @RestControllerAdvice + Query onError)
✅ Env variable: Vite VITE_*, Spring application-{profile}.yml
อย่าใส่ secret ใน frontend env — visible to all users
✅ Auto-generate TypeScript type จาก OpenAPI → backend + frontend sync


← บทที่ 0 | บทที่ 2 → Auth Flow End-to-End