Skip to content

บทที่ 5 — Routing (React Router)

← บทที่ 4 | สารบัญ | บทที่ 6: Styling →

🟡 ระดับ: กลาง

คำศัพท์ที่ใช้บ่อยในบทนี้ (เริ่มจากที่ต้องรู้ก่อน ส่วนที่เหลือจะอธิบายซ้ำอีกทีตอนถึงจุดที่ใช้จริง):

  • 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 page
  • path="*" — 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) — ต้อง setup QueryClientProvider ใน 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)
    };
}

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
DeclarativeApp เล็ก-กลาง, ทีมคุ้น <Routes> JSX, ใช้ TanStack Query handle data อยู่แล้ว<Routes><Route path="..." /></Routes>
Dataต้องการ loader/action ใน route, fetch ก่อน rendercreateBrowserRouter([...])
Framework (รวม Remix)Full SSR/SSG ไม่ต้อง Next.js, deploy เป็น full-stacknpx create-react-router@latest
bash
# Framework mode = competitor ตรงของ Next.js
npx create-react-router@latest my-app

10.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 กับ validateSearch

Setup — 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

$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>;
}
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 conventionsReact 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

✅ Type-safe route params + path

ใช้ library เช่น typed-routes หรือ TanStack Router (type-safe เต็มที่)

❌ อย่าใช้ 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
Performanceprofiling, code splitting, memoization
Storybookcomponent-driven dev
Next.jsfull-stack React (SSR, SSG, server actions)
Animation (Framer Motion)UI/UX สวยขึ้น
Forms ขั้น advancedwizard, conditional, async validation
Internationalization (i18n)หลายภาษา
Accessibility (a11y)ใช้ได้กับ screen reader

ฝึกอะไรต่อ

  1. Clone open source React project (Cal.com, Excalidraw, ...) อ่านโค้ด
  2. ทำ side project: TODO+auth+filter+CRUD ครบ flow
  3. ต่อ frontend (React) กับ backend (Spring Boot) ที่เรียนมา
  4. ลอง Next.js สำหรับ SSR + SEO
  5. Deploy บน Vercel/Netlify

← กลับสารบัญ React | ← กลับสารบัญหลัก