สัปดาห์ที่ 4

React State + Form + TanStack Router (Routing & Navigation)

CLO3
📖 ทฤษฎี

useState — State คืออะไร และทำไมต้อง Re-render

State คือข้อมูลภายใน Component ที่เมื่อเปลี่ยนแปลงแล้ว React จะ Re-render Component นั้นโดยอัตโนมัติ ต่างจาก Variable ธรรมดาที่เปลี่ยนค่าแล้ว UI ไม่อัปเดต

useState คือ Hook พื้นฐานสำหรับจัดการ State ใน Function Component:

  • ประกาศด้วย const [value, setValue] = useState(initialValue)value คือค่าปัจจุบัน, setValue คือฟังก์ชันสำหรับอัปเดต
  • เรียก setValue(newValue) จะทำให้ React กำหนดค่าใหม่ให้ State แล้ว Re-render Component นั้นและ Children ทั้งหมด
  • ห้ามแก้ไข State โดยตรง: ต้องใช้ Setter เสมอ เช่น setCount(count + 1) ไม่ใช่ count = count + 1
  • เมื่อ State ใหม่เป็น Object หรือ Array ต้องสร้างของใหม่: setForm(prev => ({ ...prev, name: 'ใหม่' }))
หมายเหตุ — ทำไม React ต้องรู้ว่า State เปลี่ยน

React เปรียบ State ใหม่กับเก่าด้วย Object.is() (Shallow Comparison) หากแก้ไข Object โดยตรง (obj.name = 'ใหม่') React จะคิดว่าไม่มีการเปลี่ยนแปลง เพราะ Reference ยังเหมือนเดิม จึงต้องสร้าง Object ใหม่ด้วย Spread Operator เสมอ

useEffect — Side Effects และ Dependency Array

Side Effect คือการทำงานที่ไม่ใช่การ Render UI โดยตรง เช่น การดึงข้อมูลจาก API, การตั้ง Timer, การ Subscribe Events, หรือการแก้ไข DOM โดยตรง

useEffect รับ 2 Arguments: ฟังก์ชัน Effect และ Dependency Array:

  • useEffect(() => { ... }) — รันทุกครั้งที่ Render (ไม่แนะนำ ส่วนใหญ่ทำให้เกิด Loop)
  • useEffect(() => { ... }, []) — รันแค่ครั้งเดียวตอน Component Mount (เหมาะสำหรับดึงข้อมูลครั้งแรก)
  • useEffect(() => { ... }, [id]) — รันเมื่อ id เปลี่ยน (เหมาะสำหรับดึงข้อมูลตาม ID)
  • Cleanup Function: ถ้า Effect สร้าง Subscription หรือ Timer ให้ return ฟังก์ชัน Cleanup: return () => clearInterval(timer)
Lifecycle ของ Component กับ useEffect:

  Component Mount
       │
       ▼
  Render (JSX → DOM)
       │
       ▼
  useEffect รัน ◄──── Deps เปลี่ยน ────┐
       │                                 │
       ▼                                 │
  (รอ...)                           Re-render
       │                                 ▲
       ▼                                 │
  Component Unmount              setState() เรียก
       │
       ▼
  Cleanup Function รัน (ถ้ามี)
          

Controlled Form ใน React

Controlled Component คือ Form Input ที่ค่าถูกควบคุมโดย React State ทุกการเปลี่ยนแปลงใน Input จะอัปเดต State ผ่าน Event Handler และ React จะ Render Input ด้วยค่าใหม่จาก State (Single Source of Truth)

  • onChange: Event Handler ที่รับ e.target.value แล้วเรียก Setter setValue(e.target.value)
  • value: ผูก Input กับ State ด้วย value={formState.field}
  • onSubmit + preventDefault: ใช้ e.preventDefault() ป้องกัน Browser Default Behavior (รีโหลดหน้า) แล้วจัดการข้อมูลใน JavaScript
  • Dynamic Key Technique: ใช้ [e.target.name]: e.target.value เพื่อ Update หลาย Fields ด้วย Handler ฟังก์ชันเดียว
เคล็ดลับ — Uncontrolled vs Controlled

Uncontrolled ใช้ ref อ่านค่าตอน Submit (DOM เก็บค่าเอง) Controlled ใช้ State เก็บทุก Keystroke — แนะนำสำหรับ Validation แบบ Real-time, ปิด/เปิดปุ่ม Submit ตามเงื่อนไข, หรือต้องการ Sync ค่ากับ UI อื่น

TanStack Router — ทำไมใช้แทน React Router

TanStack Router (เดิมชื่อ React Router v6 แบบ File-based ของ TanStack) เป็น Routing Library รุ่นใหม่ที่ออกแบบมาสำหรับ TypeScript-first และ Type-safe Routes ในคอร์สนี้เราใช้ JavaScript แต่ยังคงได้ประโยชน์จาก API ที่ชัดเจนและ Predictable

เปรียบเทียบกับ React Router v6:

  • Type-safe params: TanStack Router รู้ว่า $serviceId เป็น param ทำให้ Auto-complete ทำงานได้ดีกว่า
  • Explicit Route Tree: กำหนด Route ทั้งหมดในไฟล์เดียว (หรือแยกไฟล์) ทำให้มองเห็น Structure ของแอปได้ชัดเจน
  • Loader & Search Params: รองรับ Data Fetching per route และ Validated Search Params ได้ในตัว
  • DevTools: มี TanStack Router DevTools ที่ช่วย Debug routes ได้

Link, useParams และ createRoute

TanStack Router มี 3 Concept หลักที่ใช้บ่อย:

  • <Link to="/services"> — Component สำหรับ Navigate ระหว่างหน้า ต่างจาก <a href> ตรงที่ไม่รีโหลดหน้า (Client-side Navigation)
  • useParams() — Hook สำหรับดึง URL Parameters เช่น const { serviceId } = useParams({ from: serviceDetailRoute.id }) จาก URL /services/123
  • createRoute() — ฟังก์ชันสำหรับสร้าง Route Object กำหนด path, component, และ getParentRoute
  • createRootRoute() — สร้าง Root Route ที่เป็น Parent ของทุก Route โดยปกติ Render <Outlet /> เพื่อแสดง Child Route
  • createRouter() — รวม Route Tree ทั้งหมดเข้าด้วยกัน แล้วส่งให้ <RouterProvider router={router} /> ใน main.jsx
ข้อควรระวัง — Dynamic Segment

TanStack Router ใช้ $ นำหน้า Dynamic Segment เช่น /services/$serviceId ต่างจาก React Router ที่ใช้ : เช่น /services/:serviceId ระวังสับสนเมื่ออ่านตัวอย่างจากอินเทอร์เน็ต

💻 โค้ดตัวอย่าง
src/components/ServiceList.jsx JSX
// snippet 1: useState + useEffect
import { useState, useEffect } from 'react';

function ServiceList() {
  const [services, setServices] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // จำลองการโหลดข้อมูล
    setTimeout(() => {
      setServices([
        { id: 1, name: 'ตัดผมชาย', price: 150 },
        { id: 2, name: 'นวดแผนไทย', price: 400 },
      ]);
      setLoading(false);
    }, 1000);
  }, []); // [] = รันแค่ครั้งเดียวตอน mount

  if (loading) return <p>กำลังโหลด...</p>;
  return (
    <ul>
      {services.map(s => <li key={s.id}>{s.name} — {s.price} บาท</li>)}
    </ul>
  );
}

export default ServiceList;
src/router.js JavaScript
// snippet 2: ตั้งค่า TanStack Router
// src/router.js
import { createRouter, createRoute, createRootRoute } from '@tanstack/react-router';

const rootRoute = createRootRoute();

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: HomePage,
});

const servicesRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/services',
  component: ServicesPage,
});

const serviceDetailRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/services/$serviceId',
  component: ServiceDetailPage,
});

export const router = createRouter({
  routeTree: rootRoute.addChildren([indexRoute, servicesRoute, serviceDetailRoute]),
});
src/components/BookingForm.jsx JSX
// snippet 3: Controlled Form — Booking Form
import { useState } from 'react';

function BookingForm({ serviceId }) {
  const [form, setForm] = useState({ date: '', time: '', notes: '' });

  function handleChange(e) {
    setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));
  }

  async function handleSubmit(e) {
    e.preventDefault();
    console.log('ส่งข้อมูลจอง:', { serviceId, ...form });
    // จะเชื่อม API จริงใน Week 7
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="date" name="date" value={form.date} onChange={handleChange} required />
      <select name="time" value={form.time} onChange={handleChange} required>
        <option value="">-- เลือกเวลา --</option>
        <option value="09:00">09:00</option>
        <option value="10:00">10:00</option>
        <option value="11:00">11:00</option>
      </select>
      <textarea name="notes" value={form.notes} onChange={handleChange} placeholder="หมายเหตุ" />
      <button type="submit">ยืนยันการจอง</button>
    </form>
  );
}

export default BookingForm;

🧪 ปฏิบัติการ Lab 3 — BookEasy: Routing & Booking Form

เพิ่ม TanStack Router เข้าโปรเจกต์ สร้างหน้าต่าง ๆ ด้วย Pages และสร้าง Booking Form แบบ Controlled

ต่อยอดจาก Lab 2

ต่อยอดจาก Lab 2 — เพิ่ม routing และ booking form เข้าไปในโปรเจกต์ bookeasy

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

ติดตั้ง TanStack Router และตั้งค่า Route Tree ครบทุกเส้นทางที่แอปต้องการ

  • รัน npm install @tanstack/react-router ภายใน folder bookeasy/
  • สร้าง src/router.js พร้อม Root Route และ Routes สำหรับ: /, /services, /services/$serviceId, /booking
  • แก้ไข src/main.jsx ให้ import router และ RouterProvider แล้วแทน <App /> ด้วย <RouterProvider router={router} />
  • Root Route Component ต้อง Render <Outlet /> เพื่อแสดง Child Route ที่ตรงกับ URL ปัจจุบัน
เกณฑ์การผ่าน

เปิด Dev Server แล้วไม่มี Console Error เกี่ยวกับ Router เข้า URL / แล้วเห็นหน้า Home (แม้ยังว่างอยู่ก็ได้) ไม่มี "Cannot find module" ใน terminal

คำแนะนำ

สร้าง RootLayout Component ที่ Import Outlet จาก @tanstack/react-router แล้วใส่ใน createRootRoute({ component: RootLayout }) ตรวจสอบว่า src/router.js Export router ด้วย Named Export ไม่ใช่ Default Export

2
สร้าง Pages

สร้าง Page Components ทั้ง 3 หน้าและเชื่อมกับ Router ที่ตั้งค่าไว้

  • สร้าง src/pages/HomePage.jsx — แสดง Hero Section พร้อมชื่อแอปและ ปุ่ม/Link "ดูบริการทั้งหมด" ที่ใช้ <Link to="/services">
  • สร้าง src/pages/ServicesPage.jsx — แสดง ServiceCard จาก Mock Data ที่มีอยู่ แต่ละ Card ต้องมี Link ไปยัง /services/{id} โดยใช้ <Link to={`/services/${service.id}`}>
  • สร้าง src/pages/ServiceDetailPage.jsx — ดึง serviceId ด้วย useParams() แสดงรายละเอียดบริการที่ตรงกัน และ Render BookingForm พร้อมส่ง serviceId เป็น prop
  • สร้าง src/components/BookingForm.jsx — Controlled Form ที่มี Input: วันที่ (date), เวลา (select), หมายเหตุ (textarea) และปุ่ม "ยืนยันการจอง"
  • อัปเดต src/router.js ให้ Import Page Components ที่สร้างใหม่และผูกกับ Route แต่ละตัว
เกณฑ์การผ่าน

เข้า / เห็น HomePage, เข้า /services เห็นรายการบริการพร้อม Link, เข้า /services/1 เห็นรายละเอียดบริการ ID 1 และ BookingForm แสดงอยู่ด้านล่าง ไม่มี Error ใน Console ทุกหน้า

คำแนะนำ

ใน ServiceDetailPage ใช้ const { serviceId } = route.useParams() (หรือ useParams() ขึ้นกับ TanStack Router version) แล้วค้นหาข้อมูลจาก Mock Array ด้วย MOCK_SERVICES.find(s => String(s.id) === serviceId) เพราะ URL Param จะเป็น String เสมอ

3
ทดสอบ Navigation

ตรวจสอบว่า Routing ทำงานถูกต้องทุก Scenario รวมถึง Browser History

  • คลิก Link "ดูบริการทั้งหมด" จาก HomePage ไปยัง ServicesPage ตรวจว่า URL เปลี่ยนเป็น /services
  • คลิก ServiceCard ใด ๆ ไปยัง ServiceDetailPage ตรวจว่า URL เป็น /services/{id} และแสดงข้อมูลถูกต้อง
  • กดปุ่ม Back ของเบราว์เซอร์ ต้องกลับไปหน้าก่อนหน้าได้โดยไม่รีโหลด
  • ทดสอบ Booking Form: กรอกวันที่, เลือกเวลา, กด "ยืนยันการจอง" แล้วดู Console Log ว่าแสดงข้อมูลครบถ้วน
  • ทดสอบ Validation: กดปุ่ม Submit โดยไม่กรอกวันที่ เบราว์เซอร์ต้องแสดง Error ว่า Field นั้น Required
เกณฑ์การผ่าน

Navigate ระหว่างทุกหน้าได้โดยไม่รีโหลด กด Back/Forward ของเบราว์เซอร์ทำงานได้ถูกต้อง Console Log แสดงข้อมูล Form เมื่อ Submit เช่น { serviceId: "1", date: "2025-07-01", time: "09:00", notes: "" } ส่งภาพหน้าจอหน้า ServiceDetailPage พร้อม Booking Form ให้อาจารย์

คำแนะนำ

หากเปิด URL โดยตรง (เช่น /services/1) แล้วได้ 404 ให้แก้ไข vite.config.js เพิ่ม server: { historyApiFallback: true } เพื่อให้ Vite Redirect ทุก Route กลับมาที่ index.html