โหมดมืด
บทที่ 5 — Routing (React Router)
🟡 ระดับ: กลาง
คำศัพท์ที่ใช้บ่อยในบทนี้ (เริ่มจากที่ต้องรู้ก่อน ส่วนที่เหลือจะอธิบายซ้ำอีกทีตอนถึงจุดที่ใช้จริง):
- routing — การกำหนดว่า URL ไหนแสดงหน้าจอ/component ไหน
- navigation — การย้ายไปมาระหว่างหน้า
- SPA (Single Page Application) — เว็บที่โหลด HTML ครั้งเดียว แล้วเปลี่ยนเนื้อหาใน JS โดยไม่ reload หน้าใหม่
- bundle — ไฟล์ JS ที่ Vite/Webpack รวมโค้ดทั้งหมดให้เป็นไฟล์เดียวหรือหลายไฟล์เพื่อส่งให้ browser
- loader — function ที่ router เรียกก่อน render หน้า (รอ data เสร็จก่อนค่อยวาด) — จะเจอละเอียดใน §10
- action — function ที่รับ form submit ฝั่ง router — จะเจอละเอียดใน §10
- Outlet — จุดใน layout ที่ React Router ใส่ nested route ให้อัตโนมัติ — จะเจอละเอียดใน §7
- nested route — route ย่อยซ้อนอยู่ใน route หลัก เช่น /admin/users ซ้อนใน /admin
- lazy loading — โหลด component เฉพาะเมื่อจำเป็น ไม่โหลดทั้งหมดตั้งแต่แรก — จะเจอละเอียดใน §11
- code splitting — แบ่ง bundle ใหญ่เป็นชิ้นเล็ก ๆ เพื่อให้โหลดเร็วขึ้น — จะเจอละเอียดใน §11
แอป React เป็น SPA (Single Page Application — เว็บหน้าเดียว) — ต่างจากเว็บปกติ (MPA — เว็บที่ browser โหลด HTML ใหม่ทุกครั้งที่คลิกลิงก์) ตรงที่ SPA โหลด HTML แค่ครั้งเดียว แล้ว JavaScript สลับ content เอง โดยไม่ reload หน้าใหม่ ทุก navigation (การเปลี่ยนหน้า) เกิดใน JS
แต่ user ยังต้องการ:
- URL เปลี่ยนเมื่อเปลี่ยนหน้า (
/users/1) - Back/forward button ใช้ได้
- bookmark + share URL ได้
นี่คืองานของ router — บทนี้ใช้ React Router v7
1. ติดตั้ง
React ไม่มี router ในตัว — ต้องติดตั้ง library แยก ที่นิยมสุดคือ React Router v7
📌 ประวัติย่อ: React Router v6 กับ Remix เดิมเป็น library แยกกัน — Nov 2024 รวมกันเป็น React Router v7 (GA = General Availability หมายถึง stable พร้อมใช้งานจริง) Remix เป็น framework full-stack คู่แข่ง Next.js
bash
npm install react-router🎯 Package name update (v7): ปัจจุบันใช้
react-router(package เดียว) ไม่ใช่react-router-domแล้ว
- v6 และก่อนหน้า:
import ... from 'react-router-dom'- v7 (current):
import ... from 'react-router'react-router-domยังใช้ได้ (re-export ทุกอย่างจากreact-router) แต่ไม่แนะนำให้ใช้แล้ว (not recommended) — v7 official docs ชี้ให้ใช้react-routerโดยตรง ตัวอย่างเก่าใน blog ส่วนใหญ่ยังใช้react-router-domระวังตอน copy
2. Setup พื้นฐาน
tsx
// main.tsx
import { BrowserRouter } from 'react-router';
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />
</BrowserRouter>
);tsx
// App.tsx
import { Routes, Route, Link } from 'react-router';
function App() {
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users">Users</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users" element={<UserList />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
);
}<Routes>— wrap routes ทั้งหมด<Route path="..." element={<Component />}>— match path → render component<Link to="...">— เปลี่ยน URL โดยไม่ reload pagepath="*"— match ทุกอย่างที่ไม่ match อันบน (catch-all)
⚠️ อย่าใช้
<a href="/path">— มัน reload หน้าทั้ง app, เสีย state
3. Path Parameter
ส่วนใหญ่ URL มีค่าที่เปลี่ยนได้ (เช่น user id) — กำหนดเป็น path parameter ด้วย :id ใน path แล้วอ่านค่าใน component ด้วย useParams มักใช้ค่านี้เป็น key ของ query เพื่อดึงข้อมูลของ resource นั้น:
tsx
<Routes>
<Route path="/users/:id" element={<UserProfile />} />
</Routes>ใน component:
📌 หมายเหตุ: ตัวอย่างนี้ใช้ TanStack Query จากบทที่ 4 (
useQuery,usersApi) — ต้อง setupQueryClientProviderในmain.tsxก่อน ถ้ายังไม่ได้ setup ให้ดูบทที่ 4 §2
tsx
import { useParams } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { usersApi } from '../api/usersApi'; // import function ที่ fetch ข้อมูล user
function UserProfile() {
// useParams รับ type ของ params ที่คาดหวัง (TypeScript only) — `<{ id: string }>` บอกว่าคาด param ชื่อ `id` เป็น string
const { id } = useParams<{ id: string }>();
const { data, isLoading } = useQuery({
queryKey: ['user', id],
// id! = non-null assertion (บอก TS ว่า id ไม่เป็น undefined แน่ๆ เพราะ route กำหนด :id ไว้เสมอ — ทบทวนบทที่ 1 §3.2)
queryFn: () => usersApi.get(id!),
enabled: !!id,
});
if (isLoading) return <p>Loading...</p>;
return <div>{data?.name}</div>;
}หลาย param:
tsx
<Route path="/posts/:postId/comments/:commentId" element={<Comment />} />
const { postId, commentId } = useParams();4. Query String
ค่าที่เป็น filter/search/pagination มักเก็บใน query string (?q=hello&page=2) แทน path — ใช้ useSearchParams อ่าน/เขียน เก็บ state ใน URL แบบนี้ดีเพราะ user bookmark หรือแชร์ลิงก์ได้ และกด back/forward ได้:
URL: /search?q=hello&page=2
tsx
import { useSearchParams } from 'react-router';
function Search() {
const [searchParams, setSearchParams] = useSearchParams();
const q = searchParams.get('q') ?? '';
const page = Number(searchParams.get('page') ?? 0);
return (
<div>
<input
value={q}
onChange={e => setSearchParams({ q: e.target.value, page: '0' })}
/>
<button onClick={() => setSearchParams({ q, page: String(page + 1) })}>
Next
</button>
</div>
);
}⚠️ Production ควร debounce: ตัวอย่างข้างบนเรียก
setSearchParamsทุกครั้งที่พิมพ์ (ทุก keystroke) ซึ่งจะทำให้ URL/history ถูกอัปเดตถี่มากและ re-render บ่อยเกินจำเป็น — ในโปรเจกต์จริงควร debounce (หน่วงเวลา) ก่อนเรียกsetSearchParamsเช่นใช้useDebounce(ดูบทที่ 2) เหมือนที่ checkpoint การค้นหาในบทที่ 4 ทำ
5. Programmatic Navigation
<Link> ใช้ตอน user คลิก แต่บางครั้งต้อง navigate จากในโค้ด (เช่นหลัง login สำเร็จ) — ใช้ useNavigate สั่งเปลี่ยนหน้าได้ พร้อม option เช่น replace (ไม่เพิ่ม history) หรือ navigate(-1) (ย้อนกลับ):
tsx
import { useNavigate } from 'react-router';
function LoginForm() {
const navigate = useNavigate();
const handleLogin = async () => {
await login({ email, password });
// ⚠️ เลือก 1 บรรทัดเท่านั้น — ถ้าเรียกหลายบรรทัดพร้อมกัน หน้าจะเปลี่ยนหลายครั้งติดกันเร็วมาก (navigate รัว) ทำให้ UX พัง
navigate('/dashboard'); // ไป path
navigate('/dashboard', { replace: true }); // ไม่ push history (แทน entry ปัจจุบัน)
navigate(-1); // ย้อนกลับ (เหมือนกด back)
navigate(1); // ไปข้างหน้า (เหมือนกด forward)
};
}6. NavLink — link ที่รู้ว่า active
NavLink คือ Link เวอร์ชันพิเศษที่รู้ว่าตัวเอง active อยู่ไหม (URL ปัจจุบันตรงกับ to) — รับ isActive มาใช้กำหนด class/style ได้ เหมาะกับทำเมนูที่ต้อง highlight ข้อที่กำลังอยู่:
tsx
import { NavLink } from 'react-router';
<NavLink
to="/users"
className={({ isActive }) => isActive ? 'active' : ''}
>
Users
</NavLink>ใช้ทำ menu ที่ highlight ข้อ active
7. Nested Route — Layout
ทุกหน้าใน admin section มี sidebar + header เหมือนกัน — ใช้ nested route:
tsx
<Routes>
<Route path="/" element={<PublicLayout />}>
<Route index element={<Home />} /> {/* / */}
<Route path="about" element={<About />} /> {/* /about */}
</Route>
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Dashboard />} /> {/* /admin */}
<Route path="users" element={<UserList />} /> {/* /admin/users */}
<Route path="users/:id" element={<UserDetail />} /> {/* /admin/users/:id */}
<Route path="settings" element={<Settings />} />
</Route>
</Routes>tsx
// AdminLayout.tsx
import { Outlet } from 'react-router';
function AdminLayout() {
return (
<div className="admin">
<Sidebar />
<main>
<Outlet /> {/* child route render ที่นี่ — React Router ใส่หน้าย่อยตรงนี้อัตโนมัติ */}
</main>
</div>
);
}<Outlet> = "เอา nested route ใส่ตรงนี้"
Index route
tsx
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Dashboard />} /> // /admin (no extra path)
</Route>index = "default child" เมื่อ path ตรง parent
8. Protected Route — login required
หลายหน้าต้อง login ก่อนเข้า — ทำ "ProtectedRoute" ที่เช็ค auth: ถ้ายังไม่ login ก็ <Navigate> ไปหน้า login (พร้อมจำ path เดิมไว้ใน state เพื่อ redirect กลับหลัง login) ถ้า login แล้วค่อย render เนื้อหา:
tsx
import { Navigate, useLocation } from 'react-router';
import type { ReactNode } from 'react';
// useAuth = custom hook ที่ต้องสร้างเอง (หรือดูบท Auth Context) — ตัวอย่าง stub:
// const useAuth = () => ({ user: null as User | null, isLoading: false });
// ในโปรเจกต์จริงจะ return ข้อมูลจาก AuthContext (ดูบท Authentication)
function ProtectedRoute({ children }: { children: ReactNode }) {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <Spinner />;
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}
// ใช้
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>หรือ wrap layout:
tsx
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route path="users" element={<UserList />} />
</Route>Redirect back after login
tsx
function Login() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname ?? '/';
// email, password มาจาก useState ของ form — ดูบทที่ 3 เรื่อง Form สำหรับตัวอย่างเต็ม
// เช่น: const [email, setEmail] = useState(''); const [password, setPassword] = useState('');
// login = function จาก AuthContext ที่เรียก API แล้วเก็บ user state
const handleLogin = async () => {
await login({ email, password });
navigate(from, { replace: true });
};
}9. Route ที่ require role
นอกจากแค่ login บางหน้าต้องการ role เฉพาะ (เช่น admin) — ต่อยอด ProtectedRoute เป็น RoleRoute ที่เช็ค role ด้วย ถ้าไม่มีสิทธิ์ก็ redirect ไปหน้า login หรือ 403:
⚠️ สำคัญด้านความปลอดภัย: RoleRoute เป็นเพียง UX guard ฝั่ง client เท่านั้น — ช่วยให้ user ไม่เห็น UI ที่ไม่ควรเห็น แต่ไม่ได้ป้องกัน API ได้จริง เพราะ roles อยู่ใน client-side state ที่สามารถถูก tamper ได้ ต้องตรวจสอบสิทธิ์จริงทุกครั้งฝั่ง server (enforce ฝั่ง server) ที่ API endpoint ทุกตัวด้วย
tsx
// User type ตัวอย่าง — backend ต้องส่ง roles มาเป็น array of string
// type User = { id: string; name: string; roles: string[] }
// เช่น roles: ['USER', 'ADMIN'] — API ของคุณต้องระบุ role ที่ valid ไว้
function RoleRoute({ role, children }: { role: string; children: ReactNode }) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" />;
if (!user.roles.includes(role)) return <Navigate to="/forbidden" />;
return <>{children}</>;
}
<Route
path="/admin/users"
element={<RoleRoute role="ADMIN"><UserList /></RoleRoute>}
/>10. Data Router (React Router v7) — loader + framework mode
🟡 มือใหม่อ่านผ่านๆ ได้ก่อน — §2-9 (Declarative mode) พอสำหรับเริ่มต้นและใช้งานจริงได้แล้ว ส่วน §10 นี้เป็น "ทางเลือกขั้นถัดไป" ที่มีคำศัพท์ใหม่หนาแน่นกว่าเดิม ถ้ายังไม่คุ้น React Router พื้นฐาน แนะนำข้ามไปอ่านทีหลังได้
📖 คำศัพท์ใน section 10 (อ่านก่อน):
- loader = function ที่ router เรียก ก่อน render หน้า → router รอ data เสร็จก่อนแล้วค่อยวาด (เทียบกับ TanStack Query ที่ fetch หลัง render แล้วโชว์ spinner)
- action = function ที่รับ form submit ฝั่ง router (แทน
onSubmitของเดิม) — router จัดการ POST + invalidate + redirect ให้- invalidate = ทำให้ data เก่าหมดอายุ → router rerun loader ของหน้านั้น
- stream = ส่ง data เป็น chunk แทนรอครบแล้วส่งทีเดียว (
<Await>รอ + แสดง fallback ระหว่างรอ)- fetcher = ตัวช่วยส่ง request ที่ไม่เปลี่ยน URL (เช่น กด like, vote)
- inline mutation = แก้ข้อมูลในที่ — ไม่ต้องเปลี่ยนหน้า เช่น ปุ่ม like/follow
💡 คุณอยู่ mode ไหนตอนนี้? ถ้าทำตาม §2-§9 มา = Declarative mode ทุกอย่างที่เรียนยัง valid! §10 คือ "ทางเลือก" ที่ router จัดการ data ให้เอง (แทน TanStack Query)
10.1 v7 มี 3 modes
ก่อนเริ่ม — ทำความเข้าใจ 3 mode ที่แตกต่างกันใน v7:
| Mode | ใช้เมื่อ | ตัวอย่าง syntax |
|---|---|---|
| Declarative | App เล็ก-กลาง, ทีมคุ้น <Routes> JSX, ใช้ TanStack Query handle data อยู่แล้ว | <Routes><Route path="..." /></Routes> |
| Data | ต้องการ loader/action ใน route, fetch ก่อน render | createBrowserRouter([...]) |
| Framework (รวม Remix) | Full SSR/SSG ไม่ต้อง Next.js, deploy เป็น full-stack | npx create-react-router@latest |
bash
# Framework mode = competitor ตรงของ Next.js
npx create-react-router@latest my-app10.2 Data Mode — loader pattern (รูปแบบที่ router เรียก function โหลดข้อมูลก่อน render หน้า)
แทน fetch ใน component — ใช้ loader ใน route:
tsx
import { createBrowserRouter, RouterProvider } from 'react-router';
// fetchUser = function ที่คุณสร้างเองสำหรับดึงข้อมูล user จาก API
// เช่น: const fetchUser = (id: string): Promise<User> => fetch(`/api/users/${id}`).then(r => r.json())
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{
path: 'users/:id',
element: <UserProfile />,
loader: async ({ params }) => {
return await fetchUser(params.id!);
},
},
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
// UserProfile.tsx
import { useLoaderData } from 'react-router';
function UserProfile() {
// `as User` = type cast ที่ไม่ validate ตอน runtime — ถ้า API ส่งข้อมูลผิดรูปแบบจะเกิด bug โดยไม่มี error
// production code ควร validate ด้วย Zod ใน loader function ก่อน return เช่น:
// const user = UserSchema.parse(await fetchUser(params.id!));
const user = useLoaderData() as User;
return <div>{user.name}</div>;
}ข้อดี:
- ไม่มี loading state ใน component — router รอ loader resolve ก่อน render
- Parallel — multiple loaders run พร้อมกัน
- Cancel on navigation
- ทำงานเหมือน "next page fetched before paint" (โหลดหน้าถัดไปก่อนวาด — เหมือน native app = แอปที่ install บนเครื่อง เช่น iOS/Android app ที่ข้อมูลพร้อมแล้วค่อยเปลี่ยนหน้า ทำให้รู้สึกเร็วทันที)
10.3 actions — form submission ที่ data router จัดให้
นอกจาก loader (GET) — มี action (POST/PUT/DELETE) ที่จัดการ form submit:
tsx
import { createBrowserRouter, redirect } from 'react-router';
const router = createBrowserRouter([
{
path: 'todos/new',
element: <NewTodoForm />,
action: async ({ request }) => {
const formData = await request.formData();
const title = formData.get('title') as string;
const todo = await todosApi.create({ title });
return redirect(`/todos/${todo.id}`);
},
},
]);
// NewTodoForm.tsx — ใช้ <Form> แทน <form>
import { Form } from 'react-router';
function NewTodoForm() {
return (
<Form method="post">
<input name="title" />
<button type="submit">Create</button>
</Form>
);
}→ ไม่ต้องเขียน onSubmit handler — <Form> ของ React Router ส่งให้ action อัตโนมัติ + invalidate loader + redirect
10.4 useNavigation — รู้ว่ากำลัง navigate อยู่
tsx
import { useNavigation } from 'react-router';
function GlobalSpinner() {
const navigation = useNavigation();
const isLoading = navigation.state === 'loading';
const isSubmitting = navigation.state === 'submitting';
if (isLoading) return <TopProgressBar />;
if (isSubmitting) return <SubmittingBanner />;
return null;
}navigation.state มี 3 ค่า: 'idle' | 'loading' | 'submitting'
→ ใช้ทำ global loading indicator แบบ YouTube/Instagram (เส้นโหลดด้านบน)
10.5 useFetcher — submit form/fetch data โดยไม่ navigate
tsx
import { useFetcher } from 'react-router';
function LikeButton({ postId }: { postId: string }) {
const fetcher = useFetcher();
const isLiking = fetcher.state === 'submitting';
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<button disabled={isLiking}>
{isLiking ? '❤️...' : '❤️ Like'}
</button>
</fetcher.Form>
);
}ต่าง <Form> ทั่วไป: fetcher.Form ไม่เปลี่ยน URL (ไม่ navigate). ใช้สำหรับ inline mutation = แก้ข้อมูลในที่ (ไม่เปลี่ยนหน้า) เช่น กด Like หรือ Follow โดยไม่ต้องไป URL ใหม่ เช่น ปุ่ม like, vote, follow
⚠️ หลัง fetcher submit เสร็จ — loader ของทุก route ที่กำลังแสดงอยู่บนหน้าจะ revalidate (rerun) อัตโนมัติเหมือน navigation ปกติ เพื่อ sync ข้อมูลให้ตรงกับ server ถ้าไม่ต้องการให้ทุก loader รันใหม่ ต้องจัดการ scope การ invalidate เอง
10.6 Streaming loader — return promise ตรง ๆ (v7)
🎯 v7 API update:
defer()ของ v6 ถูก ลบใน v7 (deprecated และไม่ export แล้ว) — ใช้ return promise ตรง ๆ จาก loader ได้เลย และ<Await>รับ promise โดยตรง🔄 ถ้า upgrade จาก v6: เปลี่ยน
return defer({ user, posts })เป็นreturn { user, posts }(return object ตรง ๆ แทน)
tsx
import { Await, useLoaderData } from 'react-router';
import { Suspense } from 'react';
const router = createBrowserRouter([
{
path: 'profile/:id',
loader: async ({ params }) => {
const user = await fetchUser(params.id!); // fast — block render
const posts = fetchUserPosts(params.id!); // slow — return promise ไม่ await
return { user, posts }; // v7: return ตรงๆ ไม่ต้อง defer()
},
element: <Profile />,
},
]);
function Profile() {
const { user, posts } = useLoaderData() as { user: User; posts: Promise<Post[]> };
return (
<>
<h1>{user.name}</h1>
<Suspense fallback={<PostsSkeleton />}>
<Await resolve={posts}>
{(loadedPosts: Post[]) => <PostList posts={loadedPosts} />}
</Await>
</Suspense>
</>
);
}→ Profile หัว render ทันที (เพราะ user fast), posts list stream (ทยอยส่ง — ส่งเป็นชิ้น ๆ ไม่ต้องรอครบทีเดียว) มาทีหลังเมื่อพร้อม
10.7 Alternative: TanStack Router — type-safe เต็มที่
🔴 ส่วนนี้ optional — ข้ามได้เลยถ้าเพิ่งเริ่มเรียน นี่คือ library คนละตัวกับ React Router ไม่ใช่ v8 หรือ extension — ถ้าเพิ่งเริ่ม ให้ข้ามไปก่อนแล้วกลับมาอ่านเมื่อพร้อม
ปี 2026 TanStack Router เริ่ม mainstream — type-safe ระดับที่ React Router v7 ไม่ทำ:
bash
npm install @tanstack/react-router
npm install --save-dev @tanstack/router-plugin
npm install @tanstack/zod-adapter # สำหรับใช้ Zod กับ validateSearchSetup — file-based routing (Vite)
ts
// vite.config.ts
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [TanStackRouterVite(), react()],
});text
src/routes/
├── __root.tsx ← root layout
├── index.tsx ← /
├── about.tsx ← /about
└── users.$id.tsx ← /users/:idตัวอย่าง: type-safe params + search
$id ในชื่อไฟล์ = path parameter เหมือน :id ที่เรียนไปก่อนหน้าใน Declarative mode (ไฟล์ชื่อ users.$id.tsx แปลว่า route /users/:id)
tsx
// src/routes/users.$id.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { zodValidator } from '@tanstack/zod-adapter'; // ต้องใช้ adapter เพื่อใช้ Zod กับ validateSearch
export const Route = createFileRoute('/users/$id')({
// validate search params ที่ compile time — ต้อง wrap schema ด้วย zodValidator
validateSearch: zodValidator(z.object({
tab: z.enum(['posts', 'comments']).default('posts'),
page: z.number().int().min(0).default(0),
})),
loader: ({ params }) => fetchUser(params.id),
component: UserDetail,
});
function UserDetail() {
const { id } = Route.useParams(); // typed: { id: string }
const { tab, page } = Route.useSearch(); // typed: { tab: 'posts' | 'comments'; page: number }
const user = Route.useLoaderData(); // typed: User
return <div>...</div>;
}Type-safe Link
tsx
import { Link } from '@tanstack/react-router';
<Link to="/users/$id" params={{ id: '123' }} search={{ tab: 'posts', page: 0 }}>
View User
</Link>
// ❌ TypeScript จะแจ้ง error:
<Link to="/users/$id" params={{ wrong: 'x' }}> // TypeScript แจ้ง error: 'wrong' ไม่ใช่ชื่อ param ที่ถูกต้อง
<Link to="/users/$id" params={{ id: '1' }} search={{ tab: 'invalid' }}> // TypeScript แจ้ง error: 'invalid' ไม่อยู่ใน enum ที่กำหนด
<Link to="/nonexistent"> // TypeScript แจ้ง error: path '/nonexistent' ไม่มีอยู่ใน route ที่ define ไว้เมื่อไหร่เลือก React Router vs TanStack Router
| สถานการณ์ | เลือก |
|---|---|
| App เก่าที่ใช้ React Router อยู่ | React Router v7 (migrate path มี) |
| ต้องการ SSR/SSG ในตัว | React Router v7 framework mode หรือ Next.js |
| Project ใหม่ + อยาก type safety สูงสุด | TanStack Router |
| ใช้ TanStack Query/Table อยู่แล้ว | TanStack Router (ecosystem เดียวกัน) |
| Team คุ้น Remix conventions | React Router v7 framework mode |
11. Lazy Loading — code splitting
import('./Dashboard') = dynamic import — โหลด module นั้นตอน runtime (ตอนที่ user เปิดหน้า) ไม่ใช่ตอน build ต่างจาก import Dashboard from './Dashboard' ที่โหลดทันทีตอนเริ่มต้น ทำให้ bundle เริ่มต้นเล็กลงและ app โหลดเร็วขึ้น
tsx
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
<Routes>
<Route path="/dashboard" element={
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
} />
</Routes>React.lazy + dynamic import → Vite/Webpack แยก bundle → โหลดเฉพาะหน้าที่ใช้
bundle ใหญ่ → SPA แตก chunk (แบ่ง bundle ใหญ่เป็นชิ้นเล็ก ๆ) → first load (โหลดครั้งแรก) เร็วขึ้น
12. Scroll behavior
ปัญหาที่เจอบ่อยใน SPA: browser ปกติจำตำแหน่ง scroll ไว้ให้ — แต่ใน SPA เพราะไม่มีการ reload จริง browser อาจค้างที่ตำแหน่งเก่าเมื่อเปลี่ยนหน้า
แก้ด้วย component ScrollToTop ที่ window.scrollTo(0,0) ทุกครั้งที่ pathname เปลี่ยน:
⚠️ Tradeoff:
scrollTo(0, 0)แบบนี้ทำให้กด back/forward แล้ว scroll ไม่กลับตำแหน่งเดิม (browser native scroll restoration จะไม่ทำงาน) React Router v7 framework mode มี<ScrollRestoration>component ที่ handle tradeoff นี้ได้ดีกว่า
แก้:
tsx
import { useEffect } from 'react';
import { useLocation } from 'react-router';
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}
// ใน App
<ScrollToTop />
<Routes>...</Routes>13. ตัวอย่างเต็ม — App structure
ปิดท้ายบทด้วยโครงสร้าง app จริงที่ประกอบทุกอย่าง — BrowserRouter + nested layout + protected/role routes + lazy loading + provider (QueryClient, Auth) ซ้อนกันอย่างถูกลำดับ เป็น template ที่นำไปเริ่มโปรเจกต์ใหม่ได้:
📌 หมายเหตุ:
AuthProviderและAuthContextที่ใช้ใน template นี้ไม่ได้แสดง implementation ไว้ในบทนี้ — จะอธิบายในบท Authentication โดยละเอียด ตอนนี้ดูเป็น pattern ภาพรวมก่อนได้
tsx
// main.tsx
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route, Outlet } from 'react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './auth/AuthContext';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
);
// App.tsx
function App() {
return (
<Routes>
{/* Public */}
<Route path="/" element={<PublicLayout />}>
<Route index element={<Home />} />
<Route path="login" element={<Login />} />
<Route path="signup" element={<Signup />} />
</Route>
{/* Protected */}
{/* ProtectedRoute รับ AppLayout เป็น children — เพราะ ProtectedRoute ของเรา return <>{children}</>
AppLayout ต้องมี <Outlet> เพื่อให้ child routes render ข้างใน
(pattern นี้: ProtectedRoute เช็ค auth, AppLayout จัด layout, child routes เป็นเนื้อหา) */}
<Route element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route path="dashboard" element={<Dashboard />} />
<Route path="users" element={<UserList />} />
<Route path="users/:id" element={<UserDetail />} />
<Route path="settings" element={<Settings />} />
</Route>
{/* Admin only */}
<Route element={<RoleRoute role="ADMIN"><AdminLayout /></RoleRoute>}>
<Route path="admin/audit" element={<AuditLog />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
);
}
// PublicLayout.tsx
function PublicLayout() {
return (
<>
<PublicNavbar />
<main><Outlet /></main>
<Footer />
</>
);
}
// AppLayout.tsx
function AppLayout() {
return (
<div className="flex">
<Sidebar />
<main className="flex-1">
<TopBar />
<Outlet />
</main>
</div>
);
}14. Best Practice
✅ ใช้ component-level data fetching (TanStack Query)
- Loader pattern อ่านง่าย แต่ TanStack Query มี cache ที่ดีกว่า
- กลยุทธ์ผสม: loader สำหรับ critical (สำคัญที่ต้องโหลดก่อน เช่นข้อมูลหลักของหน้า), useQuery สำหรับ secondary (รอง — โหลดต่อได้)
✅ Lazy load big page
- Admin section, detail page
✅ ใช้ NavLink สำหรับ active highlight
✅ Type-safe route params + path
ใช้ library เช่น typed-routes หรือ TanStack Router (type-safe เต็มที่)
❌ อย่าใช้ <a> แทน <Link> (จะ reload)
❌ อย่าใช้ route สำหรับ modal
สำหรับ modal เล็ก ๆ ใช้ local state — แต่ถ้าต้อง deep-link (share URL ของ modal) ใช้ search param
15. Checkpoint
📝 หมายเหตุ: checkpoint บทนี้ ตั้งใจไม่มีเฉลย ให้ลองทำเองก่อน ถ้าติดให้ย้อนไปดูตัวอย่างโค้ดในบทแล้วดัดแปลง
🛠️ Checkpoint 5.1 — โครงสร้าง multi-page
ทำ app มี:
/Home (public)/login,/signup/users— list (protected)/users/:id— detail (protected)/admin— admin only
🛠️ Checkpoint 5.2 — Search ผ่าน URL
ทำ search page ที่ q + page อยู่ใน URL — share URL ได้ + back/forward ใช้ได้
🛠️ Checkpoint 5.3 — Lazy load
แยก admin section ออกเป็น chunk แยก ใช้ React.lazy + Suspense
16. สรุปบท
✅ React Router v7 = standard ปี 2026 ✅ <BrowserRouter> + <Routes> + <Route> + <Link> — พื้นฐาน ✅ useParams, useSearchParams, useNavigate, useLocation — hooks หลัก ✅ Nested route + <Outlet> — layout reuse ✅ Protected route + Navigate — guard ✅ Lazy loading + React.lazy → code splitting ✅ Data Router (loader) — optional pattern สำหรับ SSR-like ✅ ใช้ NavLink สำหรับ active state ✅ Scroll to top on route change ✅ React Router v7 Framework Mode = SSR/SSG full-stack (Remix merger) — alternative ของ Next.js ✅ TanStack Router = type-safe router (params + search params typed) ✅ View Transitions API (startViewTransition) = animate ระหว่างเปลี่ยน route — API ใหม่ของ browser ที่ทำ transition แบบ animate ระหว่างเปลี่ยนหน้า, รองรับใน Chrome/Safari modern (Firefox ยังไม่รองรับ cross-document View Transitions ณ ปี 2026 — ตรวจสอบ caniuse.com ก่อน deploy production เพราะยังเป็น progressive enhancement ไม่ใช่ feature ที่ใช้ได้ทุก browser) ดูตัวอย่างได้จาก MDN View Transitions API
🎉 จบ React Book (Part 1)
หลังจากบทที่ 0-5 คุณมี skill base ของ React — สามารถสร้าง SPA ครบ flow ได้
หัวข้อต่อยอด
| หัวข้อ | ทำไม |
|---|---|
| State management (Zustand, Redux Toolkit) | global state ซับซ้อน |
| Testing (Vitest + React Testing Library) | unit + integration test |
| Performance | profiling, code splitting, memoization |
| Storybook | component-driven dev |
| Next.js | full-stack React (SSR, SSG, server actions) |
| Animation (Framer Motion) | UI/UX สวยขึ้น |
| Forms ขั้น advanced | wizard, conditional, async validation |
| Internationalization (i18n) | หลายภาษา |
| Accessibility (a11y) | ใช้ได้กับ screen reader |
ฝึกอะไรต่อ
- Clone open source React project (Cal.com, Excalidraw, ...) อ่านโค้ด
- ทำ side project: TODO+auth+filter+CRUD ครบ flow
- ต่อ frontend (React) กับ backend (Spring Boot) ที่เรียนมา
- ลอง Next.js สำหรับ SSR + SEO
- Deploy บน Vercel/Netlify