โหมดมืด
บทที่ 1 — เชื่อม React กับ Spring Boot
บทนี้ลงมือทำเรื่องที่สำคัญที่สุด: ทำให้ 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 เริ่มต้นให้ฟรี):
- เปิดเว็บ https://start.spring.io
- เลือก Project: Maven, Language: Java, Spring Boot: 3.3+ (เลขล่าสุดที่ไม่ใช่ SNAPSHOT)
- กรอก Group:
com.example, Artifact:backend, Java: 21 - คลิกปุ่ม ADD DEPENDENCIES ทางขวา แล้วเพิ่ม: Spring Web, Spring Data JPA, PostgreSQL Driver, Validation, Lombok
- กดปุ่ม GENERATE สีเขียวข้างล่าง → จะได้ไฟล์
backend.zip - แตก 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 dbPart 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จาก originhttp://localhost:5173ถูกบล็อกด้วยกฎ CORS" — คือเบราว์เซอร์ห้ามเรียกข้าม origin นั่นเอง
3. ทำไม CORS มี?
Browser block การ "อ่านคำตอบ" จาก request ที่ข้าม origin (ออ-ริ-จิน) เพื่อความปลอดภัย — กฎพื้นฐานนี้เรียกว่า same-origin policy (SOP)
Origin = protocol + host + port:
http://localhost:5173≠http://localhost:8080(port ต่าง)https://app.com≠http://app.com(protocol ต่าง)https://app.com≠https://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 preflightOPTIONSด้วย 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
5. ทางแก้ที่ 2 — Vite Proxy (recommended สำหรับ dev)
ดีกว่า — 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 hostVite 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.com → API ที่ 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รอ Promisexเสร็จแล้วเอาค่าออกมา- 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.tsimportuseAuthStoreตรง ๆ จะกลายเป็นว่า API layer "รู้จัก" UI layer (store + router) — ทดสอบยาก, reuse ข้ามแอปไม่ได้, dependency เป็นวงกลม การให้ผูกผ่าน callback ตอน bootstrap ทำให้ client เป็นแค่ "ตัวยิง HTTP" บริสุทธิ์
⚠️ เรื่อง race ระหว่าง redirect กับ error toast: เมื่อเกิด 401 →
onUnauthorizedredirect ไปหน้า 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 เพื่อให้สั้น — ในการใช้จริงเพิ่ม parametersignal?: 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 เป็น
all→lists()→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 คืน
nextCursortoken แทน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 module → HTTP 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 Appts
// 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: INFObash
# 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.jar19. 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) {}บรรทัดเดียวจะได้ fieldorigins, constructor, getterorigins(),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+ จะ throwIllegalArgumentExceptionตอน 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.log | log แค่ method + URL — อย่า log header |
| Forget error handling — silent fail | global onError ใน QueryClient |
| Type ต่างฝั่ง — Java DTO กับ TS interface ไม่ตรง | ใช้ tools generate (openapi-typescript, springdoc + openapi-generator) |
| Date เป็น string ใน JSON | parse กลับเป็น 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 มา อ่านจากซ้ายไปขวาทีละวงเล็บ:
paths= รวมทุก endpoint ของ API['/api/users/{id}']= เลือก endpoint นี้ (path ดึง user ตาม id)['get']= เลือก HTTP method GET ของ endpoint นั้น['responses']= ดูส่วน "คำตอบที่เป็นไปได้"['200']= เลือกคำตอบกรณีสำเร็จ (status 200)['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 headerusers.tsที่มี list/get/create/update/deleteuseUsers,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