Components and Routing
Day 2 - ชั่วโมงที่ 4: แยก Component และสร้างหน้าใหม่
เป้าหมายของชั่วโมงนี้
หลังจบชั่วโมงนี้ ผู้เรียนจะสามารถ:
- อธิบายได้ว่าทำไมไม่ควรเก็บ Type, Data และ UI ทั้งหมดไว้ใน
page.tsx - แยก
IssueForm,IssueListและStatusBadgeเป็น Component ได้ - ส่งข้อมูลเข้า Component ผ่าน Props ได้
- ย้าย Type และ Mock Data ไปไว้ในไฟล์กลางได้
- สร้าง Route
/issues,/issues/newและ/issues/[id]ได้ - ใช้
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
โครงหน้า HomeCode ยังทำงานได้ แต่ไฟล์เดียวต้องรับผิดชอบทั้งข้อมูล 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.tsCopy 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.tsFolder data ใช้เก็บข้อมูลตัวอย่างที่ Component หลายตัวต้องนำไปใช้ร่วมกัน ทำให้เราไม่ต้องประกาศข้อมูลชุดเดิมซ้ำในแต่ละหน้า
create-next-app ตั้งค่า Import Alias @/ ให้แล้ว เราจึงใช้ @/types/issue เพื่ออ้างถึงไฟล์ types/issue.ts จาก Project Root ได้ทันที
- Copy
const issuesทั้งหมดจากapp/page.tsx - เพิ่ม Import Type ไว้ด้านบน
- เติม
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.tsxCopy 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
- Cut
<section aria-labelledby="issue-list-title">...</section>ออกจากapp/page.tsx - แทน
return null;ด้วยreturn ( ... ); - Paste Section ที่ Cut มาไว้ภายในวงเล็บ
- อย่า 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 ให้ StatusBadgeIssueList รับผิดชอบโครง 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>- Cut Section ของ Form ทั้งก้อนออกจาก
app/page.tsxโดยไม่เอา<main>มาด้วย - สร้าง Component เริ่มต้นด้วย
return null
export function IssueForm() {
return null;
}- แทน
return null;ด้วยreturn ( ... ); - 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เป็น Stringasyncทำให้ 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