FileEditViewTerminal
IT Issue Bootcamp - Visual Studio Code
day-2-hour-4.mdx

Day 2 / Hour 4

Components and Routing

Component extraction, shared types, mock data files, and basic routes.

60 minutes
The app uses reusable components and routes.

Components and Routing

Day 2 - ชั่วโมงที่ 4: แยก Component และสร้างหน้าใหม่

เป้าหมายของชั่วโมงนี้

หลังจบชั่วโมงนี้ ผู้เรียนจะสามารถ:

  1. อธิบายได้ว่าทำไมไม่ควรเก็บ Type, Data และ UI ทั้งหมดไว้ใน page.tsx
  2. แยก IssueForm, IssueList และ StatusBadge เป็น Component ได้
  3. ส่งข้อมูลเข้า Component ผ่าน Props ได้
  4. ย้าย Type และ Mock Data ไปไว้ในไฟล์กลางได้
  5. สร้าง Route /issues, /issues/new และ /issues/[id] ได้
  6. ใช้ Link เชื่อมหน้าใน Next.js ได้

ไฟล์ที่จะได้หลังจบชั่วโมง

app/
  layout.tsx
  page.tsx
  issues/
    page.tsx
    new/
      page.tsx
    [id]/
      page.tsx
components/
  IssueForm.tsx
  IssueList.tsx
  StatusBadge.tsx
data/
  issue.ts
types/
  issue.ts

โครงสร้างเวลา 60 นาที

เวลาหัวข้อรูปแบบ
0-10 นาทีทำไมต้องแยกไฟล์ และ Component คืออะไรอธิบาย
10-25 นาทีย้าย Type, Data และ Status Badgeทำทีละขั้นตอน
25-40 นาทีแยก Issue List และ Issue Formทำทีละขั้นตอน
40-52 นาทีสร้าง Routes และ Dynamic Routeทำทีละขั้นตอน
52-60 นาทีเพิ่ม Navigation และตรวจผลลัพธ์ตรวจความพร้อม

Slide 1: page.tsx เริ่มทำหลายหน้าที่เกินไป

ท้าย Hour 3 ทุกอย่างยังอยู่ใน app/page.tsx:

IssueStatus และ Issue
Mock Data
getStatusClass()
Form JSX
Table JSX
โครงหน้า Home

Code ยังทำงานได้ แต่ไฟล์เดียวต้องรับผิดชอบทั้งข้อมูล Logic และ UI ทำให้อ่านยากขึ้น และหน้าอื่นไม่สามารถนำ Form หรือ Table ไปใช้ซ้ำได้

Hour นี้เราจะ Refactor หรือปรับโครงสร้าง Code โดยยังคงผลลัพธ์เดิม แต่แยกแต่ละหน้าที่ไปอยู่ในไฟล์ที่เหมาะสม


Slide 2: Component และ Props คืออะไร

Component คือ Function ที่คืน UI และนำกลับมาใช้ซ้ำได้:

export function StatusBadge() {
  return <span>OPEN</span>;
}

หาก Component ต้องแสดงข้อมูลที่เปลี่ยนไปในแต่ละครั้ง เราส่งข้อมูลเข้าไปผ่าน Props:

<StatusBadge status="OPEN" />
Parent ส่ง Props → Component รับข้อมูล → Component สร้าง UI

คำว่า export ทำให้ไฟล์อื่น Import Component นี้ไปใช้ได้ ส่วนชื่อ Prop เช่น status บอกว่า Component ต้องการข้อมูลอะไร


Slide 3: ย้าย Type ไปไว้ในไฟล์กลาง

สร้าง Folder types ไว้ที่ Root ของ Project ระดับเดียวกับ Folder app แล้วสร้างไฟล์ issue.ts ไว้ข้างใน:

project/
├── app/
└── types/
    └── issue.ts

Copy IssueStatus และ Issue จาก app/page.tsx มาไว้ในไฟล์ใหม่ แล้วเพิ่ม export:

export type IssueStatus = "OPEN" | "IN_PROGRESS" | "DONE";
 
export type Issue = {
  id: string;
  reporterName: string;
  reporterEmail: string;
  title: string;
  description: string;
  status: IssueStatus;
  adminComment?: string;
  createdAt: string;
};

เราต้อง export เพราะไฟล์ Component และ Data จะ Import Type ทั้งสองตัวไปใช้ร่วมกัน

ยังไม่ต้องลบ Type เดิมจาก app/page.tsx เพื่อให้หน้าเดิมทำงานต่อระหว่าง Refactor เราจะลบพร้อม Code เดิมทั้งหมดใน Slide 9


Slide 4: สร้าง Folder สำหรับ Mock Data

สร้าง Folder data ไว้ที่ Root ของ Project ระดับเดียวกับ app และ types แล้วสร้างไฟล์ issue.ts ไว้ข้างใน:

project/
├── app/
├── types/
│   └── issue.ts
└── data/
    └── issue.ts

Folder data ใช้เก็บข้อมูลตัวอย่างที่ Component หลายตัวต้องนำไปใช้ร่วมกัน ทำให้เราไม่ต้องประกาศข้อมูลชุดเดิมซ้ำในแต่ละหน้า

create-next-app ตั้งค่า Import Alias @/ ให้แล้ว เราจึงใช้ @/types/issue เพื่ออ้างถึงไฟล์ types/issue.ts จาก Project Root ได้ทันที

  1. Copy const issues ทั้งหมดจาก app/page.tsx
  2. เพิ่ม Import Type ไว้ด้านบน
  3. เติม export หน้า const issues

โครงของไฟล์หลังย้าย:

import type { Issue } from "@/types/issue";
 
export const issues: Issue[] = [
  // ย้าย Object ทั้ง 3 รายการจาก app/page.tsx มาไว้ที่นี่
];

Object ทั้งสามรายการไม่ต้องพิมพ์ใหม่ ให้ Copy แล้ว Paste เพื่อรักษาข้อมูลเดิมและลดโอกาสสะกดผิด

ยังไม่ต้องลบ const issues เดิมจาก app/page.tsx เพราะหน้าเดิมยังใช้อยู่ เราจะเปลี่ยนให้ Import จากไฟล์ใหม่นี้ใน Slide 9


Slide 5: สร้าง Component StatusBadge

สร้าง Folder components ไว้ที่ Root ของ Project ระดับเดียวกับ app, types และ data แล้วสร้างไฟล์ StatusBadge.tsx ไว้ข้างใน:

project/
├── app/
├── types/
├── data/
└── components/
    └── StatusBadge.tsx

Copy getStatusClass() จาก app/page.tsx มาไว้ในไฟล์นี้ แล้วสร้าง Component:

import type { IssueStatus } from "@/types/issue";
 
type StatusBadgeProps = {
  status: IssueStatus;
};
 
function getStatusClass(status: IssueStatus): string {
  if (status === "OPEN") {
    return "status-open";
  }
 
  if (status === "IN_PROGRESS") {
    return "status-progress";
  }
 
  return "status-done";
}
 
export function StatusBadge({ status }: StatusBadgeProps) {
  return (
    <span className={`status ${getStatusClass(status)}`}>
      {status}
    </span>
  );
}

StatusBadgeProps กำหนดว่า Component ต้องได้รับ Prop ชื่อ status และค่านั้นต้องเป็น IssueStatus

ยังไม่ต้องลบ getStatusClass() เดิมจาก app/page.tsx จนกว่า Table จะเปลี่ยนมาใช้ StatusBadge


Slide 6: เตรียม Component IssueList

สร้างไฟล์ใหม่: components/IssueList.tsx

เพิ่ม Import และกำหนด Props ไว้ด้านบน:

import { StatusBadge } from "./StatusBadge";
import type { Issue } from "@/types/issue";
 
type IssueListProps = {
  issues: Issue[];
};
 
export function IssueList({ issues }: IssueListProps) {
  return null;
}
  • เมื่อ Component รับ Props เราควรกำหนด Type ของ Props ให้ชัดเจน ในตัวอย่างนี้ IssueListProps บอกว่า IssueList ต้องได้รับ issues ซึ่งเป็น Issue[]
  • Type ช่วยให้ TypeScript ตรวจได้ตั้งแต่ตอนเขียน Code หากลืมส่ง issues หรือส่งข้อมูลผิดรูปแบบ แต่ถ้า Component ไม่ได้รับ Props เช่น IssueForm ในตอนนี้ ก็ไม่จำเป็นต้องสร้าง Props Type
  • ชื่อใน Type และชื่อที่ Component รับต้องตรงกัน ในที่นี้ต้องใช้ issues แบบพหูพจน์ทั้งสองตำแหน่ง หากเขียน issue: Issue[] แต่รับ { issues } TypeScript จะแจ้ง Error
  • return null เป็นจุดพักที่หมายถึง “Component ยังไม่แสดงอะไร” และทำให้ไฟล์ยัง Compile ได้ ส่วน return () แบบไม่มี Element อยู่ข้างในจะเกิด Syntax Error
  • ยังไม่ต้อง Cut Table ใน Slide นี้ เพราะ Slide ถัดไปจะนำ Section มาใส่ระหว่าง return ( และ ); ให้สมบูรณ์ในครั้งเดียว

Slide 7: ย้าย Table มาไว้ใน IssueList

  1. Cut <section aria-labelledby="issue-list-title">...</section> ออกจาก app/page.tsx
  2. แทน return null; ด้วย return ( ... );
  3. Paste Section ที่ Cut มาไว้ภายในวงเล็บ
  4. อย่า Cut <main> มาด้วย เพราะ main ยังเป็นโครงของหน้า

ภายใน <tbody> ให้ใช้ Code นี้ เพื่อวนสร้างแถวจาก issues และส่ง Status ไปให้ StatusBadge:

<tbody>
  {issues.map((issue) => (
    <tr key={issue.id}>
      <td>#{issue.id}</td>
      <td>{issue.title}</td>
      <td>{issue.reporterName}</td>
      <td>
        <StatusBadge status={issue.status} />
      </td>
    </tr>
  ))}
</tbody>

Flow ของข้อมูลตอนนี้คือ:

IssueList รับ issues

.map() อ่าน issue ทีละตัว

ส่ง issue.status ให้ StatusBadge

IssueList รับผิดชอบโครง Table ส่วน StatusBadge รับผิดชอบข้อความและสีของ Status แต่ละรายการ

หลังเปลี่ยนแล้ว IssueList.tsx จะใช้ issues, issue และ StatusBadge ครบทุกตัวโดยไม่มี Code ที่เว้นว่าง ส่วน Helper เดิมใน app/page.tsx จะถูกลบเมื่อเราแทนทั้งไฟล์ใน Slide 9


Slide 8: แยก Form เป็น IssueForm

สร้างไฟล์ใหม่: components/IssueForm.tsx

ให้ Cut ตั้งแต่ Tag เปิด <section> จนถึง Tag ปิด </section> เพื่อย้ายทั้งหัวข้อและ Form ไปด้วย:

<section>
  <h2>แจ้งปัญหาใหม่</h2>
  <form>{/* fieldset และช่องกรอกข้อมูลทั้งหมด */}</form>
</section>
  1. Cut Section ของ Form ทั้งก้อนออกจาก app/page.tsx โดยไม่เอา <main> มาด้วย
  2. สร้าง Component เริ่มต้นด้วย return null
export function IssueForm() {
  return null;
}
  1. แทน return null; ด้วย return ( ... );
  2. Paste Section ของ Form ไว้ภายในวงเล็บ

ไม่ต้องพิมพ์ Form ใหม่ ให้เก็บ Section, fieldset, Label, Input, Textarea และ Button เดิมไว้ครบ

ตอนนี้ Form ยังไม่บันทึกข้อมูล การแยก Component มีเป้าหมายเพื่อจัดโครงสร้างก่อนเพิ่ม Interaction ใน Day 3


Slide 9: หน้า Home เหลือหน้าที่จัดวาง Component

แทน Code ทั้งไฟล์: app/page.tsx

import { IssueForm } from "@/components/IssueForm";
import { IssueList } from "@/components/IssueList";
import { issues } from "@/data/issue";
 
export default function Home() {
  return (
    <>
      <header>
        <h1>ระบบแจ้งปัญหา IT</h1>
        <p>แจ้งและติดตามปัญหาการใช้งานระบบภายใน</p>
      </header>
 
      <main>
        <IssueForm />
        <IssueList issues={issues} />
      </main>
 
      <footer>
        <p>ฝ่ายเทคโนโลยีสารสนเทศ</p>
      </footer>
    </>
  );
}

page.tsx ไม่ต้องรู้รายละเอียดภายใน Form หรือ Table เพียง Import แล้วจัดวาง Component พร้อมส่ง issues เข้า IssueList


Slide 10: App Router ใช้ Folder สร้าง URL

App Router คือระบบ Routing ของ Next.js ที่ใช้โครงสร้าง Folder ภายใน app เป็นตัวกำหนดว่าหน้าแต่ละหน้าจะเปิดด้วย URL อะไร

  • app คือจุดเริ่มต้นของ Route
  • ชื่อ Folder แต่ละชั้นจะกลายเป็นส่วนหนึ่งของ URL
  • page.tsx คือ UI ที่จะแสดงเมื่อผู้ใช้เปิด URL ของ Folder นั้น

จึงไม่ต้องเขียนรายการ Route แยกเอง เพียงสร้าง Folder ตาม URL ที่ต้องการ แล้วใส่ page.tsx ไว้ภายใน:

ไฟล์URL
app/page.tsx/
app/issues/page.tsx/issues
app/issues/new/page.tsx/issues/new
app/issues/[id]/page.tsx/issues/001, /issues/002, ...

เมื่อสร้างหน้าใหม่ เราจึงสร้าง Folder ตาม URL แล้วใส่ page.tsx ไว้ภายใน

Folder [id] เป็น Dynamic Segment เครื่องหมาย [ ] บอกว่า Segment นี้เปลี่ยนค่าได้ตาม Issue ที่ผู้ใช้เปิด


Slide 11: สร้างหน้ารายการปัญหา

สร้างไฟล์ใหม่: app/issues/page.tsx

import { IssueList } from "@/components/IssueList";
import { issues } from "@/data/issue";
 
export default function IssuesPage() {
  return (
    <main>
      <h1>รายการปัญหา</h1>
      <IssueList issues={issues} />
    </main>
  );
}

เปิด http://localhost:3000/issues แล้วควรเห็น Table เดิม เพราะเรานำ IssueList กลับมาใช้ซ้ำแทนการเขียน Table ใหม่


Slide 12: สร้างหน้าแจ้งปัญหาใหม่

สร้างไฟล์ใหม่: app/issues/new/page.tsx

import { IssueForm } from "@/components/IssueForm";
 
export default function NewIssuePage() {
  return (
    <main>
      <h1>แจ้งปัญหาใหม่</h1>
      <IssueForm />
    </main>
  );
}

เปิด http://localhost:3000/issues/new แล้วควรเห็น Form เดิม เพราะ IssueForm ถูกนำกลับมาใช้ซ้ำ

หน้า Home ยังมีทั้ง Form และ List เพื่อใช้ฝึก Mock CRUD ใน Day 3 ส่วน Route ที่แยกไว้จะนำไปต่อกับ Database ใน Day 4


Slide 13: เพิ่ม Link ไปหน้ารายละเอียด

แก้บางส่วน: components/IssueList.tsx

เพิ่ม Import ด้านบน:

import Link from "next/link";

เพิ่มหัว Column ต่อจาก <th>สถานะ</th>:

<th>รายละเอียด</th>

เพิ่ม Cell ต่อจาก Status ภายในแต่ละ Row:

<td>
  <Link href={`/issues/${issue.id}`}>ดูรายละเอียด</Link>
</td>

หาก issue.id เป็น "001" ค่า href จะกลายเป็น /issues/001 ส่วน <Link> ใช้เปลี่ยนหน้าใน Next.js โดยไม่ต้อง Reload หน้าเว็บทั้งหมด


Slide 14: Dynamic Route รับ id จาก URL

สร้าง Folder และไฟล์ใหม่:

app/issues/[id]/page.tsx

เมื่อเปิด /issues/001 Next.js จะส่งค่า { id: "001" } เข้ามาทาง params

ใน Next.js เวอร์ชันนี้ params เป็น Promise หน้า Dynamic Route จึงต้องเป็น async และใช้ await ก่อนอ่านค่า:

  • Promise คือค่าที่อาจยังไม่พร้อมทันที แต่จะส่งผลลัพธ์กลับมาเมื่อทำงานเสร็จ
  • Promise<{ id: string }> บอกว่าผลลัพธ์ที่จะได้รับภายหลังมี id เป็น String
  • async ทำให้ Function สามารถใช้ await ได้
  • await params รอจน params พร้อม แล้วจึงนำ id ออกมาใช้
type IssueDetailPageProps = {
  params: Promise<{
    id: string;
  }>;
};
 
export default async function IssueDetailPage({
  params,
}: IssueDetailPageProps) {
  const { id } = await params;
  // ใช้ id ค้นหา Issue
}

Slide 15: สร้างหน้ารายละเอียดจาก id

แทน Code ทั้งไฟล์: app/issues/[id]/page.tsx

import { notFound } from "next/navigation";
import { issues } from "@/data/issue";
 
type IssueDetailPageProps = {
  params: Promise<{ id: string }>;
};
 
export default async function IssueDetailPage({
  params,
}: IssueDetailPageProps) {
  const { id } = await params;
  const issue = issues.find((item) => item.id === id);
 
  if (!issue) {
    notFound();
  }
 
  return (
    <main>
      <h1>{issue.title}</h1>
      <p>{issue.description}</p>
      <p>ผู้แจ้ง: {issue.reporterName}</p>
      <p>อีเมล: {issue.reporterEmail}</p>
      <p>สถานะ: {issue.status}</p>
    </main>
  );
}

.find() คืน Issue ที่มี id ตรงกับ URL หากไม่พบ notFound() จะแสดงหน้า 404 แทนการใช้ข้อมูลที่ไม่มีอยู่


Slide 16: เพิ่ม Navigation ที่ทุกหน้าใช้ร่วมกัน

แก้บางส่วน: app/layout.tsx

เพิ่ม Import:

import Link from "next/link";

วาง <nav> ภายใน <body> และก่อน {children}:

<body>
  <nav className="app-nav" aria-label="เมนูหลัก">
    <div className="app-nav-inner">
      <Link href="/">หน้าแรก</Link>
      <Link href="/issues">รายการปัญหา</Link>
      <Link href="/issues/new">แจ้งปัญหาใหม่</Link>
    </div>
  </nav>
 
  {children}
</body>

สิ่งที่อยู่ใน layout.tsx จะครอบทุก Route เราจึงเขียน Navigation เพียงครั้งเดียวแต่เห็นได้ทุกหน้า


Slide 17: ตกแต่ง Navigation

เพิ่มต่อท้าย: app/globals.css

.app-nav {
  border-bottom: 1px solid #e5e7eb;
  background: white;
}
 
.app-nav-inner {
  max-width: 760px;
  margin: 0 auto;
  padding: 12px 24px;
  display: flex;
  gap: 16px;
  flex-wrap: wrap;
}

Navigation ใช้ความกว้างเดียวกับเนื้อหาหลัก display: flex จัด Link เป็นแถว และ flex-wrap ยอมให้ Link ขึ้นบรรทัดใหม่เมื่อหน้าจอแคบ


Slide 18: ตรวจ Project หลังจบ Day 2

ตรวจว่า:

  • Navigation กดไป /, /issues และ /issues/new ได้
  • /issues แสดง IssueList
  • /issues/new แสดง IssueForm
  • Link ใน Table เปิด /issues/001 และแสดงรายละเอียดถูกต้อง
  • /issues/999 แสดงหน้า 404
  • Terminal และ Browser ไม่มี Error หรือ Warning

ตอนนี้ Type, Data และ UI ถูกแยกตามหน้าที่แล้ว Day 3 จะใช้ Component ชุดนี้ปรับหน้าตาด้วย Tailwind และเพิ่ม Interaction ให้ Form