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]และหน้า Not Found ได้ - ใช้
Linkเชื่อมหน้าใน Next.js ได้
ไฟล์ที่จะได้หลังจบชั่วโมง
app/
layout.tsx
not-found.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 นาที | เพิ่มหน้า Not Found, 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 และนำกลับมาใช้ซ้ำได้ ตัวอย่างนี้รับ status ผ่าน Props แล้วนำค่ามาแสดง:
export function StatusBadge(props: { status: string }) {
return <span>{props.status}</span>;
}Parent ส่งค่าที่ต้องการผ่าน Attribute ของ Component:
<StatusBadge status="OPEN" />เมื่อเรียก Component ค่า "OPEN" จะเข้ามาอยู่ใน props.status แล้วแสดงผลภายใน <span>
ตัวอย่างนี้ใช้ตัวแปร props เพื่อให้เห็นเส้นทางของข้อมูลชัดเจน ส่วน Slide 7 จะแยก Type เป็น StatusBadgeProps และใช้ Destructuring ดึง status ออกมาใช้งานโดยตรง
Slide 3: แยก IssueStatus ไปไว้ในไฟล์กลาง
export type IssueStatus = "OPEN" | "IN_PROGRESS" | "DONE";
export ทำให้ไฟล์อื่น Import Type นี้ไปกำหนดค่าของ Status ได้
Slide 4: เพิ่ม Type Issue ในไฟล์กลาง
export type Issue = { id: string; reporterName: string; reporterEmail: string; title: string; description: string; status: IssueStatus; adminComment?: string; createdAt: string;};
ยังไม่ต้องลบ Type เดิมจาก app/page.tsx เพื่อให้หน้า Home ทำงานต่อระหว่าง Refactor
Slide 5: แยก Mock Data ไปไว้ในไฟล์กลาง
เพิ่ม Import Type ด้านบนและเติม export หน้า const issues โดยเก็บ Object ทั้งสามรายการไว้เหมือนเดิม:
import type { Issue } from "@/types/issue"; export const issues: Issue[] = [ // คัดลอก Object ทั้ง 3 รายการจาก app/page.tsx มาไว้ที่นี่];
@/ อ้างถึง Project Root ส่วนการคัดลอก Array เดิมทั้งก้อนช่วยรักษาข้อมูล Mock ทั้งสามรายการไว้ครบ
ยังไม่ต้องลบ issues เดิมจาก app/page.tsx เพราะหน้าเดิมจะเปลี่ยนมา Import ไฟล์ใหม่นี้ใน Slide 11
Slide 6: สร้าง Component StatusBadge
import type { IssueStatus } from "@/types/issue"; function getStatusClass(status: IssueStatus): string { if (status === "OPEN") { return "status-open"; } if (status === "IN_PROGRESS") { return "status-progress"; } return "status-done";}
Helper นี้ย้าย Logic เลือก Class ออกจากหน้า Home และรับได้เฉพาะค่าจาก IssueStatus
Slide 7: เติม UI ให้ StatusBadge
type StatusBadgeProps = { status: IssueStatus;};export function StatusBadge({ status }: StatusBadgeProps) { return ( <span className={`status ${getStatusClass(status)}`}> {status} </span> );}
StatusBadgeProps กำหนดว่า Component ต้องได้รับ Prop ชื่อ status และค่านั้นต้องเป็น IssueStatus ส่วน { status } คือ Destructuring ที่ดึงค่าเดียวกันออกจาก Props ทำให้ด้านใน Component ใช้ status แทน props.status ได้
Helper เดิมใน app/page.tsx ยังเก็บไว้จนกว่า Table จะเปลี่ยนมาใช้ Component นี้
Slide 8: สร้าง IssueList และกำหนด Props
import { StatusBadge } from "./StatusBadge";import type { Issue } from "@/types/issue"; type IssueListProps = { issues: Issue[];}; export function IssueList({ issues }: IssueListProps) { return null;}
IssueListProps กำหนดว่า Component ต้องได้รับ issues ซึ่งเป็น Issue[] และ { issues } คือการดึงค่านั้นออกจาก Props ส่วน return null เป็นจุดพักที่ทำให้ไฟล์ Compile ได้โดยยังไม่แสดง UI
เรา Import StatusBadge เตรียมไว้ก่อน เพราะจะนำมาใช้กับ Status ของแต่ละแถวทันทีหลังย้าย Table ใน Slide 9
Slide 9: ย้าย Table มาไว้ใน IssueList
ภายใน Section ที่คัดลอกมา ให้แทน <tbody> เดิมทั้งหมดด้วย Code นี้:
<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>
IssueList รับผิดชอบโครง Table และส่ง issue.status ให้ StatusBadge สร้าง Badge ของแต่ละแถว
Slide 10: สร้าง IssueForm และย้าย Form
เริ่มไฟล์ใหม่ด้วยโครงนี้:
export function IssueForm() { return null;}
จากนั้นแทน return null; ด้วย return ( ... ); แล้วคัดลอก Section ที่มีหัวข้อ แจ้งปัญหาใหม่ ตั้งแต่ <section> ถึง </section> จาก app/page.tsx มาไว้ภายในวงเล็บ โดยไม่คัดลอก <main>
ไม่ต้องพิมพ์ Form ใหม่ ให้เก็บ Section, fieldset, Label, Input, Textarea และ Button เดิมไว้ครบ และยังไม่ต้องลบ Section เดิมจาก app/page.tsx เพราะ Slide 11 จะแทน <main> เดิมด้วย Components ที่แยกไว้
ตอนนี้ Form ยังไม่บันทึกข้อมูล การแยก Component มีเป้าหมายเพื่อจัดโครงสร้างก่อนเพิ่ม Interaction ใน Day 3
Slide 11: ให้ Home จัดวาง Component
แทน Code ทั้งหมดตั้งแต่บรรทัดแรกจนถึงก่อน export default function Home() ด้วย Import เหล่านี้:
import { IssueForm } from "@/components/IssueForm";import { IssueList } from "@/components/IssueList";import { issues } from "@/data/issue";
จากนั้นแทน <main> เดิมทั้งก้อนภายใน Home() ด้วย Code นี้:
<main> <IssueForm /> <IssueList issues={issues} /></main>
Header, Footer และ Fragment เดิมยังอยู่ ส่วน Form และ Table จะแสดงผ่าน Component ที่แยกไฟล์แล้ว
Slide 12: จากหน้าเดียวสู่หลายหน้าด้วย Routing
ตอนนี้ Project มีเพียงหน้า / แต่เราต้องการแยกหน้ารายการปัญหาและหน้าแจ้งปัญหาใหม่ให้มี URL ของตัวเอง การกำหนดว่าแต่ละ URL ต้องแสดงหน้าใดเรียกว่า Routing
ใน Next.js App Router ให้มอง app เป็นจุดเริ่มต้นของเว็บไซต์ ชื่อ app และ page.tsx จะไม่ปรากฏใน URL ส่วนชื่อ Folder ที่อยู่ระหว่างกลางจะกลายเป็นส่วนของ URL:
app/
├─ page.tsx → /
└─ issues/
├─ page.tsx → /issues
└─ new/
└─ page.tsx → /issues/new- Folder
issuesเพิ่ม/issuesต่อจาก URL หลัก - Folder
newที่อยู่ข้างในเพิ่ม/newต่อท้ายอีกส่วน page.tsxทำให้ URL ณ ตำแหน่งนั้นเปิดเป็นหน้าเว็บได้
ดังนั้น app/issues/new/page.tsx จึงเปิดผ่าน /issues/new ส่วน Folder แบบ [id] ที่รับค่าเปลี่ยนไปได้จะเรียนแยกใน Slide 17
Slide 13: สร้างหน้ารายการปัญหา
import { IssueList } from "@/components/IssueList";import { issues } from "@/data/issue"; export default function IssuesPage() { return ( <main> <h1>รายการปัญหา</h1> <IssueList issues={issues} /> </main> );}
ไฟล์นี้ทำให้ /issues เปิดเป็นหน้าเว็บได้ โดย IssueList รับข้อมูล issues ผ่าน Props และนำ Table เดิมกลับมาใช้โดยไม่ต้องเขียนใหม่
เปิด http://localhost:3000/issues แล้วควรเห็นหัวข้อ รายการปัญหา และ Table เดิม
Slide 14: สร้างหน้าแจ้งปัญหาใหม่
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 15: เตรียม Column สำหรับ Link รายละเอียด
import Link from "next/link";
<th>รายละเอียด</th>
Link เป็น Component ของ Next.js สำหรับนำทางไปยัง Route ภายใน Project โดย Slide นี้เตรียมหัว Column ไว้ก่อน และ Slide 16 จะเพิ่ม <td> ที่มี Link ให้กับทุก Row
Slide 16: เพิ่ม Link ไปหน้ารายละเอียดของแต่ละ Issue
<td> <Link href={`/issues/${issue.id}`}>ดูรายละเอียด</Link></td>
หาก issue.id เป็น "001" ค่า href จะกลายเป็น /issues/001 ส่วน <Link> ช่วยเปลี่ยน Route ภายใน Project โดยไม่ Reload เอกสารทั้งหน้า
ตอนนี้ Route /issues/001 ยังไม่มี Page รองรับ หากคลิกแล้วพบหน้า Not Found ถือว่าเป็นผลลัพธ์ที่คาดไว้ Slide 17 จะอธิบาย Dynamic Route และ Slide 18 จะสร้าง Page ปลายทาง
Slide 17: Dynamic Route หนึ่งหน้ารองรับหลาย id
เราไม่ต้องสร้าง Page แยกสำหรับ Issue 001, 002 และ 003 แต่ใช้ Folder [id] เป็นตำแหน่งรับค่าที่เปลี่ยนไปใน URL:
/issues/001 ─┐
/issues/002 ─┼─→ app/issues/[id]/page.tsx
/issues/003 ─┘เครื่องหมาย [ ] ทำให้ [id] เป็น Dynamic Segment ผู้ใช้จึงเปิด URL จริงอย่าง /issues/001 ไม่ใช่ /issues/[id]
เมื่อเปิด /issues/001 Next.js จะส่งค่า { id: "001" } ให้ Page ผ่าน params และใน Next.js เวอร์ชันนี้ params เป็น Promise
คิดง่าย ๆ ว่า Promise คือกล่องที่บอกว่า “จะมีคำตอบให้ แต่ค่าอาจยังไม่พร้อมทันที”:
await paramsรอจนค่าในกล่องพร้อม แล้วจึงได้{ id: "001" }asyncบอกว่า Function นี้มีขั้นตอนที่ต้องใช้await
Slide 18 จะนำสองคำสั่งนี้มาใช้ก่อนอ่าน id
Slide 18: สร้าง Page ปลายทางและอ่าน id
type IssueDetailPageProps = { params: Promise<{ id: string }>;}; export default async function IssueDetailPage({ params,}: IssueDetailPageProps) { const { id } = await params; return ( <main> <h1>Issue #{id}</h1> </main> );}
async ทำให้ Function ใช้ await params ได้ จากนั้น { id } จะดึงค่าที่อยู่ใน URL มาแสดง เปิด /issues/001 แล้วควรเห็น Issue #001
Slide 19: ค้นหา Issue จาก id ใน URL
import { notFound } from "next/navigation";import { issues } from "@/data/issue";
const issue = issues.find((item) => item.id === id); if (!issue) { notFound(); }
.find() คืน Issue รายการแรกที่มี id ตรงกัน หรือคืน undefined เมื่อไม่พบข้อมูล จากนั้น notFound() จะหยุดการแสดง Page นี้และเปิดหน้า Not Found ของ Next.js
ลองเปิด /issues/001 ควรพบ Issue ส่วน /issues/999 ควรแสดงหน้า Not Found
Slide 20: แสดงข้อมูลของ Issue ที่ค้นพบ
import { StatusBadge } from "@/components/StatusBadge";
return ( <main> <h1>#{issue.id} {issue.title}</h1> <p>{issue.description}</p> <p>ผู้แจ้ง: {issue.reporterName}</p> <p>อีเมล: {issue.reporterEmail}</p> <p>สถานะ: <StatusBadge status={issue.status} /></p> </main> );
Page นี้ใช้ข้อมูลจาก issue ที่ค้นพบใน Slide 19 และนำ StatusBadge เดิมกลับมาใช้เพื่อแสดงสถานะในรูปแบบเดียวกับหน้ารายการ
Slide 21: สร้างหน้า Not Found ของ Application
import Link from "next/link"; export default function NotFound() { return ( <main> <h1>ไม่พบหน้าที่ต้องการ</h1> <p>ไม่พบข้อมูลหรือหน้าที่คุณกำลังค้นหา</p> <Link href="/issues">กลับไปหน้ารายการปัญหา</Link> </main> );}
เมื่อ notFound() ทำงานหรือเปิด URL ที่ไม่มีอยู่ Next.js จะแสดง Component นี้แทน และ Link ช่วยให้ผู้ใช้กลับไปยังหน้ารายการปัญหาได้
หากยังเห็นหน้า 404 เริ่มต้น ให้หยุด Development Server ด้วย Ctrl+C แล้วรัน npm run dev ใหม่ เพื่อให้ Next.js ตรวจพบ Special File ที่เพิ่งสร้าง
Slide 22: เพิ่ม Navigation ให้ทุก Route
import Link from "next/link";
<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>
app/layout.tsx ครอบทุก Page ผ่าน {children} เมื่อวาง Navigation ไว้ก่อนตำแหน่งนี้ เมนูชุดเดียวกันจึงแสดงบน /, /issues, /issues/new และหน้ารายละเอียดโดยไม่ต้องเขียนซ้ำในแต่ละ Page
Slide 23: ตกแต่ง Navigation ให้รองรับหน้าจอแคบ
.app-nav { border-bottom: 1px solid #e5e7eb; background-color: #fff;}
.app-nav-inner { max-width: 760px; margin: 0 auto; padding: 12px 24px; display: flex; gap: 16px; flex-wrap: wrap;}
flex-wrap: wrap ทำให้ Link ขึ้นบรรทัดใหม่เมื่อพื้นที่แนวนอนไม่พอ
Slide 24: ตรวจ Project หลังจบ Day 2
- Navigation แสดงบนทุก Route และเปิด
/,/issuesกับ/issues/newได้ /issuesแสดงIssueList,StatusBadgeและ Link ของแต่ละรายการ/issues/001แสดงเลข Issue, รายละเอียด ผู้แจ้ง อีเมล และStatusBadge/issues/newแสดงIssueForm/issues/999แสดงหน้า Not Found ที่สร้างไว้และ Link กลับ/issuesได้- Terminal และ Browser ไม่มี Error ที่เกิดจาก Code ของ Project
หากพบ performance.measure ... negative time stamp เฉพาะใน npm run dev ให้ Restart Development Server แล้วทดสอบใหม่ เพราะเป็นปัญหาของ Development Runtime ไม่ใช่ Logic ของ notFound()
ตอนนี้ Type, Data และ UI ถูกแยกตามหน้าที่แล้ว Day 3 จะใช้ Component ชุดนี้ปรับหน้าตาด้วย Tailwind และเพิ่ม Interaction ให้ Form