Form State and Validation
Day 3 - ชั่วโมงที่ 3: ทำให้ Form โต้ตอบกับผู้ใช้
เป้าหมายของชั่วโมงนี้
หลังจบชั่วโมงนี้ นักศึกษาจะสามารถ:
- แยกความต่างระหว่าง Server Component และ Client Component ได้
- ใช้
"use client"เมื่อต้องใช้ State หรือ Event Handler - ใช้
useStateเก็บรายการปัญหาชั่วคราวใน Browser - อ่านค่าจาก Form ด้วย
FormData - ตรวจข้อมูลและแสดงข้อความ Error
- เพิ่มปัญหาใหม่เข้า Mock List โดยยังไม่ใช้ Database
- อธิบายได้ว่าทำไม Frontend Validation ยังไม่เพียงพอสำหรับความปลอดภัย
ไฟล์ที่ใช้
components/IssueBoard.tsx ← สร้างใหม่
components/IssueForm.tsx ← แก้ไข
app/page.tsx ← แก้ไขไฟล์ที่นำมาใช้ต่อโดยไม่แก้โครงสร้างหลัก:
components/IssueList.tsx
types/issue.ts
data/issue.tsโครงสร้างเวลา 60 นาที
| เวลา | เนื้อหา |
|---|---|
| 0-10 นาที | Server Component และ Client Component |
| 10-20 นาที | สร้าง IssueBoard และ State |
| 20-35 นาที | รับ Submit และอ่าน FormData |
| 35-50 นาที | Validation และ Error State |
| 50-60 นาที | เพิ่ม Issue เข้า List และทดสอบ |
Slide 1: Recap จาก Hour 2
ตอนนี้หน้าเว็บมี:
IssueFormสำหรับกรอกข้อมูลIssueListสำหรับแสดงรายการStatusBadgeสำหรับแสดงสีตามสถานะ- Tailwind และ Responsive Layout
แต่ Form ยังไม่เชื่อมกับ List:
กรอกข้อมูล → กดส่ง → รายการยังไม่เปลี่ยนHour นี้เราจะทำให้ข้อมูลไหลจาก Form ไปยัง List โดยเก็บไว้ใน State ของ Browser ก่อน
IssueForm → ตรวจข้อมูล → เพิ่ม State → IssueList แสดงรายการใหม่Slide 2: Server Component และ Client Component
ใน Next.js App Router หน้าและ Layout เป็น Server Component โดย Default
| Server Component | Client Component |
|---|---|
| เหมาะกับการอ่านข้อมูล | เหมาะกับ UI ที่โต้ตอบได้ |
| ใช้ Secret ฝั่ง Server ได้ | ใช้ State และ Event Handler ได้ |
| ส่ง JavaScript ไป Browser น้อยลง | ใช้ useState, onClick, onSubmit ได้ |
ใช้ window หรือ localStorage ไม่ได้ | ใช้ Browser API ได้ |
เราไม่ต้องเปลี่ยนทั้งหน้าให้เป็น Client Component ให้เลือกเฉพาะ Component ที่ต้องมี State หรือรับ Event จากผู้ใช้
ใน Hour นี้:
app/page.tsx → Server Component เหมือนเดิม
IssueBoard.tsx → Client Component เพราะมี State
IssueForm.tsx → Client Component เพราะมี onSubmit และ Error StateSlide 3: "use client" คืออะไร
"use client" เป็นคำสั่งที่ประกาศว่าไฟล์นี้เป็นจุดเริ่มต้นของ Client Component
ต้องวางไว้บรรทัดแรก ก่อน Import:
"use client";
import { useState } from "react";ใช้เมื่อ Component ต้องมี:
- State เช่น
useState - Event Handler เช่น
onSubmitหรือonClick - Browser API เช่น
windowหรือlocalStorage
Component ที่ถูก Import เข้ามาภายใต้ Client Component จะอยู่ใน Client Bundle ด้วย แต่ IssueForm ยังถูกหน้า /issues/new Import ไปใช้โดยตรง เราจึงจะใส่ "use client" ที่ IssueForm.tsx ด้วยเมื่อเพิ่ม onSubmit
Slide 4: State ควรอยู่ที่ไหน
IssueForm เป็นผู้สร้างข้อมูล ส่วน IssueList เป็นผู้แสดงข้อมูล ทั้งสอง Component จึงต้องใช้รายการเดียวกัน
เราจะสร้าง Parent Component ชื่อ IssueBoard เพื่อเก็บ State ไว้ตรงกลาง:
IssueBoard
เก็บ State: items
↙ ↘
IssueForm IssueList
สร้างข้อมูล แสดง itemsการย้าย State ไปไว้ที่ Parent ที่ใกล้ที่สุดซึ่งครอบ Component ที่ต้องใช้ข้อมูลร่วมกัน เรียกว่า Lifting State Up
State เริ่มต้นจะมาจาก Mock Data เดิม:
const [items] = useState<Issue[]>(initialIssues);เมื่อ items เปลี่ยน React จะ Render IssueList ใหม่โดยอัตโนมัติ
Slide 5: สร้าง IssueBoard
สร้างไฟล์ใหม่ components/IssueBoard.tsx แล้วใส่โค้ดนี้ทั้งไฟล์:
"use client";
import { useState } from "react";
import { IssueForm } from "./IssueForm";
import { IssueList } from "./IssueList";
import type { Issue } from "@/types/issue";
type IssueBoardProps = {
initialIssues: Issue[];
};
export function IssueBoard({ initialIssues }: IssueBoardProps) {
const [items] = useState<Issue[]>(initialIssues);
return (
<main className="mx-auto grid grid-cols-1 max-w-5xl gap-6 px-6 py-8">
<IssueForm />
<IssueList issues={items} />
</main>
);
}initialIssuesคือรายการเริ่มต้นที่รับมาทาง Propsitemsคือ State ที่ Browser จะเก็บไว้- ตอนนี้ State ยังไม่เปลี่ยน เราจะเพิ่ม Function สำหรับแก้ State หลังทำ Form เสร็จ
Slide 6: ให้หน้า Home ใช้ IssueBoard
แก้บางส่วนใน app/page.tsx
ลบ Import ของ IssueForm และ IssueList แล้ว Import IssueBoard แทน โดยยังเก็บ Import issues ไว้:
import { IssueBoard } from "@/components/IssueBoard";
import { issues } from "@/data/issue";แทน <main>...</main> เดิมด้วย:
<IssueBoard initialIssues={issues} />โครงของหน้าจะเป็น:
export default function Home() {
return (
<>
{/* Header เดิม */}
<IssueBoard initialIssues={issues} />
{/* Footer เดิม */}
</>
);
}page.tsx ยังเป็น Server Component และส่ง Array ที่อ่านจาก Mock Data เข้า IssueBoard ผ่าน Props
Slide 7: รับ Event ตอน Submit Form
แก้บางส่วนใน components/IssueForm.tsx
เพิ่ม "use client" เป็นบรรทัดแรกของไฟล์:
"use client";เพิ่ม Function นี้ไว้ภายใน IssueForm ก่อน return:
function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
event.preventDefault();
}แล้วเพิ่ม onSubmit ให้ <form> เดิม:
<form className="mt-4" onSubmit={handleSubmit}>onSubmitเรียก Function เมื่อกดปุ่ม Submitevent.preventDefault()ป้องกัน Browser Reload หน้าแบบ Form HTML ปกติ
ตอนนี้กดปุ่มแล้วหน้าไม่ Reload แต่ยังไม่ได้อ่านข้อมูลจาก Form
Slide 8: อ่านข้อมูลด้วย FormData
เพิ่ม Type นี้เหนือ Function IssueForm ในไฟล์ components/IssueForm.tsx:
type NewIssueInput = {
reporterName: string;
reporterEmail: string;
title: string;
description: string;
};จากนั้นเพิ่ม Function สำหรับอ่าน Form:
function getIssueInput(form: HTMLFormElement): NewIssueInput {
const formData = new FormData(form);
return {
reporterName: String(formData.get("reporterName") ?? "").trim(),
reporterEmail: String(formData.get("reporterEmail") ?? "").trim(),
title: String(formData.get("title") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
};
}ชื่อใน formData.get(...) ต้องตรงกับ name ของ Input เช่น:
<input name="reporterName" />formData.get("reporterName")NewIssueInput มีเฉพาะข้อมูลที่ผู้ใช้กรอก ส่วน Issue ยังมีข้อมูลที่ระบบต้องสร้างเพิ่ม เช่น id, status และ createdAt
Slide 9: ตรวจข้อมูลก่อนนำไปใช้
เพิ่ม Function นี้ต่อจาก getIssueInput():
function validateIssueInput(input: NewIssueInput): string[] {
const errors: string[] = [];
if (input.reporterName.length < 2) {
errors.push("กรุณากรอกชื่อผู้แจ้ง");
}
if (!input.reporterEmail.includes("@")) {
errors.push("กรุณากรอกอีเมลผู้แจ้งให้ถูกต้อง");
}
if (input.title.length < 5) {
errors.push("หัวข้อปัญหาต้องมีอย่างน้อย 5 ตัวอักษร");
}
if (input.description.length < 10) {
errors.push("รายละเอียดปัญหาต้องมีอย่างน้อย 10 ตัวอักษร");
}
return errors;
}Function นี้รับข้อมูลหนึ่งชุดและคืน string[]:
- ถ้าข้อมูลถูกต้อง จะได้
[] - ถ้ามีหลายจุดที่ผิด จะได้ข้อความ Error หลายรายการ
การคืน Error ทุกข้อพร้อมกันช่วยให้ผู้ใช้แก้ข้อมูลได้ในรอบเดียว
Slide 10: เก็บ Error ไว้ใน State
เพิ่ม Import ใน components/IssueForm.tsx ต่อจาก "use client":
import { useState } from "react";เพิ่ม State เป็นบรรทัดแรกภายใน IssueForm และแทน handleSubmit เดิมด้วย:
const [errors, setErrors] = useState<string[]>([]);
function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
event.preventDefault();
const input = getIssueInput(event.currentTarget);
const validationErrors = validateIssueInput(input);
if (validationErrors.length > 0) {
setErrors(validationErrors);
return;
}
setErrors([]);
}เมื่อ setErrors() เปลี่ยน State React จะ Render Component ใหม่ ทำให้เรานำ errors ไปแสดงใน UI ได้
event.currentTargetคือ<form>ที่กำลัง Submit จึงส่งเข้าgetIssueInput()เพื่ออ่านค่าจาก Form นั้น
return ภายใน if ใช้หยุดการทำงานทันที เพื่อไม่ให้นำข้อมูลที่ยังผิดไปสร้าง Issue
Slide 11: แสดงข้อความ Error ใน Form
เพิ่มโค้ดนี้ภายใน <fieldset className="grid gap-4"> หลัง <legend> และก่อน Field แรก:
{errors.length > 0 && (
<div
role="alert"
className="rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-700"
>
<p className="font-bold">กรุณาตรวจสอบข้อมูล</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
{errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
</div>
)}errors.length > 0 && (...)แสดงกล่องเฉพาะเมื่อมี Error.map()สร้าง<li>หนึ่งรายการต่อหนึ่งข้อความrole="alert"ช่วยให้เทคโนโลยีช่วยอ่านรับรู้ข้อความ Error ที่ปรากฏขึ้น
ลองกรอกข้อมูลสั้นกว่าที่กำหนดแล้วกด Submit หน้าเว็บควรแสดงรายการสิ่งที่ต้องแก้
Slide 12: เปลี่ยนข้อมูลจาก Form ให้เป็น Issue
เพิ่ม Import Type ด้านบน components/IssueForm.tsx:
import type { Issue } from "@/types/issue";เพิ่ม Function นี้ต่อจาก validateIssueInput():
function createIssueFormInput(input: NewIssueInput): Issue {
return {
id: crypto.randomUUID().slice(0, 8),
reporterName: input.reporterName,
reporterEmail: input.reporterEmail,
title: input.title,
description: input.description,
status: "OPEN",
createdAt: new Date().toISOString(),
};
}ผู้ใช้กรอกเพียงชื่อ อีเมล หัวข้อ และรายละเอียด ส่วนระบบเติม:
idด้วยcrypto.randomUUID()ชั่วคราวstatusเริ่มต้นเป็นOPENcreatedAtเป็นเวลาปัจจุบัน
ในระบบจริง Database จะเป็นผู้สร้าง ID และเวลาให้ แต่ตอนนี้เราใช้ Browser จำลองพฤติกรรมก่อน
Slide 13: ส่ง Issue ใหม่กลับไปที่ IssueBoard
1. แก้ components/IssueForm.tsx
เพิ่ม Props Type เหนือ IssueForm:
type IssueFormProps = {
onCreateIssue?: (issue: Issue) => void;
};แก้ Parameter ของ Component:
export function IssueForm({ onCreateIssue }: IssueFormProps) {ใน handleSubmit ให้วางโค้ดนี้ต่อจาก if ที่ตรวจ Error และใช้แทน setErrors([]) เดิม:
const newIssue = createIssueFormInput(input);
onCreateIssue?.(newIssue);
setErrors([]);
event.currentTarget.reset();เครื่องหมาย ?. หมายถึงให้เรียก onCreateIssue เฉพาะเมื่อ Parent ส่ง Function นี้เข้ามา หน้า /issues/new จึงยังใช้ <IssueForm /> ได้โดยไม่เกิด TypeScript Error
2. แก้ components/IssueBoard.tsx
เปลี่ยน State ให้รับ setItems แล้วเพิ่ม Handler:
const [items, setItems] = useState<Issue[]>(initialIssues);
function handleCreateIssue(issue: Issue) {
setItems((currentItems) => [issue, ...currentItems]);
}ส่ง Handler เข้า Form:
<IssueForm onCreateIssue={handleCreateIssue} />[issue, ...currentItems] สร้าง Array ใหม่ โดยวาง Issue ใหม่ไว้ด้านบนแล้วตามด้วยรายการเดิม
Slide 14: ทดสอบ Form ทีละกรณี
ทดสอบที่หน้า Home /:
- กรอกข้อมูลไม่ครบ แล้วกด Submit
- ตรวจว่ามีข้อความ Error และยังไม่มี Row ใหม่
- แก้ข้อมูลให้ครบ แล้ว Submit อีกครั้ง
- ตรวจว่า Error หาย Form ถูกล้าง และ Row ใหม่อยู่บนสุด
- เพิ่มหลายรายการแล้วตรวจว่ารายการเดิมยังอยู่ครบ
ข้อจำกัดของ Mock State ตอนนี้
- Refresh หน้าแล้วรายการที่เพิ่มจะหาย เพราะยังไม่ได้บันทึกลง Database
- หน้า
/issues/newตรวจข้อมูลได้ แต่ยังไม่บันทึกรายการ - Link รายละเอียดของ Issue ที่เพิ่งสร้างอาจเปิด 404 เพราะหน้า Detail ยังอ่านจาก Mock Data ใน
data/issue.ts
พฤติกรรมเหล่านี้เป็นข้อจำกัดที่คาดไว้ของ Client State และจะถูกแทนด้วย Database ใน Day 4
Slide 15: Frontend Validation ไม่ใช่ Security
Validation ใน Browser ช่วยให้ผู้ใช้รู้ข้อผิดพลาดเร็วขึ้น แต่เชื่อถือเป็นด่านสุดท้ายไม่ได้ เพราะผู้ใช้สามารถ:
- ปิด JavaScript
- แก้ HTML หรือ Client Code ผ่าน DevTools
- ส่ง Request ไปหา Server โดยไม่ผ่าน Form
- ข้าม Attribute เช่น
required
ดังนั้นระบบจริงต้องตรวจซ้ำฝั่ง Server:
Frontend Validation → ช่วยประสบการณ์ใช้งาน
Server Validation → ปกป้องข้อมูลก่อนบันทึกHour นี้เราฝึก Flow ฝั่ง Browser ส่วน Day 4 จะย้ายการตรวจและบันทึกข้อมูลไปฝั่ง Server
Slide 16: ภาพรวมการทำงานหลังต่อครบ
ผู้ใช้ Submit IssueForm
↓
getIssueInput() อ่าน FormData
↓
validateIssueInput() ตรวจข้อมูล
↓
ถ้าผิด → setErrors() → แสดงข้อความ
↓ ถ้าถูก
createIssueFormInput() สร้าง Issue
↓
onCreateIssue() ส่งไป IssueBoard
↓
setItems() เปลี่ยน State
↓
IssueList Render รายการใหม่แต่ละ Function มีหน้าที่เดียว ทำให้ตาม Flow และแก้ปัญหาได้ง่ายกว่าการเขียนทุกอย่างรวมไว้ใน handleSubmit
Slide 17: สรุป Hour 3
สิ่งที่ได้เรียน:
- Server Component เหมาะกับการอ่านข้อมูลและงานฝั่ง Server
- Client Component ใช้เมื่อมี State, Event หรือ Browser API
"use client"กำหนดขอบเขตของ Client ComponentIssueBoardเก็บ State กลางให้ Form และ List ใช้ข้อมูลร่วมกันFormDataอ่านค่าจาก Input โดยใช้ Attributename- Validation แยกข้อมูลที่ผิดก่อนสร้าง
Issue - State ทำให้ UI เปลี่ยนตามข้อมูลโดยไม่ Reload หน้า
- Frontend Validation ต้องมี Server Validation ตามมาเสมอ
Hour ถัดไป เราจะทดลองเปลี่ยน Status ใน Mock State ก่อนเตรียมย้ายข้อมูลไป Database
คำศัพท์สำคัญ
| คำศัพท์ | ความหมาย |
|---|---|
| Server Component | Component ที่ Next.js Render ฝั่ง Server เป็นค่าเริ่มต้น |
| Client Component | Component ที่ใช้ State, Event และ Browser API ได้ |
"use client" | คำสั่งประกาศจุดเริ่มต้นของ Client Component |
| State | ข้อมูลที่เมื่อเปลี่ยนแล้วทำให้ React Render UI ใหม่ |
| Lifting State Up | ย้าย State ไปไว้ที่ Parent ที่ Component ลูกใช้ร่วมกัน |
FormData | API สำหรับอ่านค่าจาก Form |
| Frontend Validation | การตรวจข้อมูลใน Browser เพื่อช่วยผู้ใช้ |