Form State and Validation
Day 3 - ชั่วโมงที่ 3: ทำให้ Form โต้ตอบกับผู้ใช้
เป้าหมายของชั่วโมงนี้
หลังจบชั่วโมงนี้ นักศึกษาจะสามารถ:
- แยกความต่างระหว่าง Server Component และ Client Component ได้
- ใช้
"use client"เมื่อต้องเก็บข้อมูลที่เปลี่ยนได้หรือรับการกดส่งฟอร์ม - ใช้
useStateเก็บรายการปัญหาชั่วคราวใน Browser - อ่านค่าจาก Form ด้วย
FormData - ตรวจข้อมูลและแสดงข้อความ Error
- เพิ่มปัญหาใหม่ในรายการตัวอย่าง โดยยังไม่ใช้ฐานข้อมูล
- อธิบายได้ว่าทำไมการตรวจข้อมูลใน Browser อย่างเดียวยังไม่เพียงพอ
ไฟล์ที่ใช้
components/IssueBoard.tsx ← สร้างใหม่
components/IssueForm.tsx ← แก้ไข
app/page.tsx ← แก้ไขไฟล์ที่นำมาใช้ต่อโดยไม่แก้โครงสร้างหลัก:
components/IssueList.tsx
types/issue.ts
data/issue.tsSlide 1: จาก Form ไปสู่รายการใหม่
ตอนนี้ Form และ List มีหน้าตาพร้อมแล้ว แต่การกดส่งยังไม่เพิ่มรายการ
ชั่วโมงนี้เราจะต่อการทำงาน: อ่านฟอร์ม → ตรวจข้อมูล → เพิ่มรายการใน State → แสดงรายการใหม่
State คือข้อมูลที่ Component จำไว้ เช่น รายการปัญหาปัจจุบัน เมื่อเปลี่ยนผ่านฟังก์ชันที่ React ให้มา หน้าจอจะเปลี่ยนตาม
IssueBoard จะเก็บรายการให้ฟอร์มและตารางใช้ร่วมกัน โดยเริ่มจากข้อมูลตัวอย่าง (Mock Data) เดิม
ข้อมูลใหม่ยังอยู่ชั่วคราวในหน้า Home และยังไม่บันทึกลงฐานข้อมูล (Database)
Slide 2: เลือกส่วนที่ต้องเป็น Client Component
Server Component ทำงานฝั่ง Server ซึ่งเป็นเครื่องที่ให้บริการเว็บ โดยหน้าและ Layout เป็นแบบนี้ตั้งแต่ต้น
Client Component ใช้ความสามารถฝั่ง Browser ได้ เช่น เก็บ State และรับการกดส่งฟอร์ม จึงใช้กับส่วนที่ผู้ใช้โต้ตอบ
| ไฟล์ | หน้าที่ในชั่วโมงนี้ |
|---|---|
app/page.tsx | Server: ส่ง Mock Data เริ่มต้น |
IssueBoard.tsx | Client: เก็บรายการที่เปลี่ยนได้ |
IssueForm.tsx | Client: รับการส่งฟอร์มและแสดงข้อผิดพลาด |
ตอนเปิดหน้า Next.js อาจเตรียม HTML ของ Client Component บน Server ก่อน ส่วนโค้ดรับการคลิกหรือส่งฟอร์มทำงานใน Browser
Slide 3: ขอบเขตของ use client
ตัวอย่างอธิบาย ยังไม่ต้องแก้ไฟล์:
"use client";
import { useState } from "react";"use client" อยู่ก่อน Import เพื่อกำหนดจุดเริ่มต้นของฝั่ง Client โค้ดที่นำเข้ามาใช้จากจุดนี้จะรวมอยู่ใน JavaScript ที่ส่งให้ Browser ด้วย
IssueForm ถูกใช้ทั้งจาก IssueBoard และหน้า /issues/new จึงจะประกาศขอบเขต Client ที่ไฟล์ Form ด้วย
Render คือการที่ React คำนวณว่าหน้าจอควรแสดงอะไร useState ช่วยจำข้อมูลข้ามการคำนวณแต่ละครั้ง และให้ฟังก์ชันสำหรับเปลี่ยนข้อมูลนั้น
Slide 4: State ของรายการควรอยู่ที่ไหน
IssueForm สร้างรายการ ส่วน IssueList แสดงรายการ จึงให้ IssueBoard ที่ครอบทั้งคู่เก็บข้อมูลร่วมกัน ตัวที่ครอบเรียกว่า Parent และตัวที่อยู่ภายในเรียกว่า Child
itemsคือรายการปัจจุบันinitialIssuesคือรายการเริ่มต้นที่หน้า Home ส่งมา- Form ส่งรายการใหม่กลับไปให้ Board
- Board อัปเดต State แล้วส่ง
itemsให้ List
วิธีนี้เรียกว่า Lifting State Up คือย้าย State ไปไว้ที่ Parent ที่ Component ลูกใช้ร่วมกัน
ข้อความผิดพลาดใช้เฉพาะในฟอร์ม จึงเก็บ State แยกไว้ใน IssueForm
Slide 5: สร้างไฟล์ IssueBoard
"use client"; import { useState } from "react";import { IssueForm } from "./IssueForm";import { IssueList } from "./IssueList";import type { Issue } from "@/types/issue"; type IssueBoardProps = { initialIssues: Issue[];};
Props คือค่าที่ส่งให้ Component; initialIssues จะรับรายการเริ่มต้น ขั้นถัดไปจะเพิ่ม Component ต่อท้ายไฟล์นี้
Slide 6: ให้ Board เก็บรายการและแสดงหน้าจอ
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> );}
Issue[] คือ Array หรือชุดข้อมูลหลายรายการที่แต่ละรายการเป็น Issue; useState ใช้ initialIssues เป็นค่าเริ่มต้น
ตอนนี้อ่าน items อย่างเดียว เราจะเพิ่ม Function เปลี่ยน State หลังต่อ Form แล้ว
Slide 7: เปลี่ยน Import ของหน้า Home
import { IssueForm } from "@/components/IssueForm";import { IssueList } from "@/components/IssueList";
เพิ่ม Import แทน โดยคง Import issues เดิม:
import { IssueBoard } from "@/components/IssueBoard";import { issues } from "@/data/issue";
ขั้นถัดไปจะเปลี่ยน JSX หรือโค้ดที่เขียนหน้าตาเว็บใน Component ให้เรียก IssueBoard
Slide 8: ให้ Home เรียก Board
<IssueBoard initialIssues={issues} />
<main className="mx-auto grid grid-cols-1 max-w-5xl gap-6 px-6 py-8"> <IssueForm /> <IssueList issues={issues} /></main>
ส่วนสีแดงคือโค้ดเดิมที่ลบ ไม่ครอบ Board ด้วย <main> อีกชั้น
เก็บส่วนหัว ส่วนท้าย และ <>...</> ที่ครอบเนื้อหา (Fragment) รวมถึงฟังก์ชัน Home เดิมไว้
หน้า Home ควรยังแสดง Form และรายการเดิม โดย page.tsx ยังเป็น Server Component
Slide 9: รับการส่งฟอร์ม (Submit)
"use client"; import { useState } from "react";import type { SubmitEvent } from "react";import type { Issue } from "@/types/issue";
เพิ่มฟังก์ชันรับเหตุการณ์ (Event Handler) ภายใน IssueForm ก่อน return:
function handleSubmit(event: SubmitEvent<HTMLFormElement>) { event.preventDefault();}
Event คือเหตุการณ์ เช่น การส่งฟอร์ม; SubmitEvent<HTMLFormElement> ระบุชนิดข้อมูลของเหตุการณ์นี้ ส่วน preventDefault() หยุดการส่งฟอร์มแบบเปลี่ยนหน้า
Slide 10: เชื่อม Form กับ Handler
<form className="mt-4" onSubmit={handleSubmit}>
เก็บช่องกรอก, required, type="email", ปุ่ม และ Tag ปิดเดิมไว้ครบ
onSubmit ส่งข้อมูลเหตุการณ์ให้ handleSubmit ทั้งเมื่อกดปุ่มส่งและเมื่อส่งด้วยแป้นพิมพ์
เมื่อกรอกครบแล้วกดส่ง หน้าไม่โหลดใหม่ แต่ยังไม่เพิ่มรายการ
Slide 11: กำหนดข้อมูลที่อ่านจาก Form
type NewIssueInput = { reporterName: string; reporterEmail: string; title: string; description: string;};
Type นี้มีเฉพาะสิ่งที่ผู้ใช้กรอก ส่วน Issue มี id, status และ createdAt ที่ระบบเติมเพิ่มภายหลัง
Slide 12: อ่านค่าผ่านชื่อช่องกรอก
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 ของช่องกรอก และ ?? "" ใช้ค่าว่างเมื่อไม่พบข้อมูล
String() แปลงค่าเป็นข้อความ และ .trim() ตัดช่องว่างหัวท้าย ก่อนนำไปตรวจ
Slide 13: กำหนดเงื่อนไขตรวจข้อมูล
function validateIssueInput(input: NewIssueInput): string[] { const errors: string[] = []; if (input.reporterName.length < 2) errors.push("ชื่อผู้แจ้งต้องมีอย่างน้อย 2 ตัวอักษร"); if (!input.reporterEmail.includes("@")) errors.push("กรุณาตรวจสอบอีเมลผู้แจ้ง"); if (input.title.length < 5) errors.push("หัวข้อปัญหาต้องมีอย่างน้อย 5 ตัวอักษร"); if (input.description.length < 10) errors.push("รายละเอียดต้องมีอย่างน้อย 10 ตัวอักษร"); return errors;}
การตรวจข้อมูลตามเงื่อนไขเรียกว่า Validation แต่ละ if เพิ่มข้อความผิดพลาดลง Array; ถ้าผ่านทุกข้อจะได้ []
การหา @ เป็นเพียงการตรวจเบื้องต้น และ Form ยังมี type="email" ของ Browser ช่วยตรวจอยู่
Slide 14: เก็บ Error ใน State
const [errors, setErrors] = useState<string[]>([]);
แทน handleSubmit เดิมด้วย:
function handleSubmit(event: SubmitEvent<HTMLFormElement>) { event.preventDefault(); const input = getIssueInput(event.currentTarget); const validationErrors = validateIssueInput(input); setErrors(validationErrors); if (validationErrors.length > 0) return;}
errors เก็บข้อความผิดพลาด และ setErrors เปลี่ยนข้อความนั้น; event.currentTarget คือฟอร์มที่ส่งมา ส่วน return หยุดเมื่อข้อมูลยังผิด
Slide 15: แสดงข้อความที่ต้องแก้
{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() สร้างรายการข้อความ
role="alert" ช่วยให้โปรแกรมอ่านหน้าจอรับรู้ข้อความที่เพิ่งปรากฏ
Slide 16: ลอง Error สองรูปแบบ
- เว้นช่องว่างแล้วส่ง: Browser จะเตือนจาก
requiredก่อนเรียก Handler - กรอกชื่อ
A, อีเมลa@example.com, หัวข้อabc, รายละเอียดshortแล้วส่ง - ควรเห็นกล่อง Error สามข้อจาก State และข้อมูลใน Form ยังอยู่
- แก้ชื่อเป็น
An, หัวข้อเครื่องพิมพ์เสีย, รายละเอียดเครื่องพิมพ์ชั้นสองพิมพ์งานไม่ได้แล้วส่ง - กล่อง Error ควรหาย แต่ยังไม่เพิ่มรายการ เพราะยังไม่ได้ต่อฟังก์ชันเพิ่มรายการ
Error ของ Browser และกล่องที่เราเขียนเองเป็นการตรวจคนละขั้น
Slide 17: เติมข้อมูลระบบให้เป็น Issue
function createIssueFormInput(input: NewIssueInput): Issue { return { id: crypto.randomUUID(), reporterName: input.reporterName, reporterEmail: input.reporterEmail, title: input.title, description: input.description, status: "OPEN", createdAt: new Date().toISOString(), };}
UUID คือรหัสสุ่มยาวที่ใช้แยกแต่ละรายการ; ใช้รหัสเต็มและตั้งสถานะเป็น OPEN ส่วน ISO คือรูปแบบข้อความวันเวลามาตรฐานที่ toISOString() คืนมา
Slide 18: ให้ Form รับ Function จาก Parent
type IssueFormProps = { onCreateIssue?: (issue: Issue) => void;};
แก้บรรทัดประกาศ Component เดิมเป็น:
export function IssueForm({ onCreateIssue }: IssueFormProps) {
Callback คือฟังก์ชันที่ส่งให้อีกส่วนเรียกภายหลัง เช่น Parent ส่ง onCreateIssue ให้ฟอร์มเรียกเมื่อมี Issue ใหม่; void คือไม่ใช้ค่าที่ฟังก์ชันคืนมา
? ทำให้ Prop นี้ไม่บังคับ เพราะ /issues/new ยังเรียก <IssueForm /> โดยไม่มี Callback
Slide 19: ส่งข้อมูลเมื่อผ่านการตรวจ
if (!onCreateIssue) { setErrors(["หน้านี้ยังไม่บันทึกข้อมูล กรุณาลองเพิ่มรายการที่หน้าแรก"]); return;}const newIssue = createIssueFormInput(input);onCreateIssue(newIssue);event.currentTarget.reset();
เมื่อมี Callback จะส่ง Issue กลับให้ Parent แล้วล้าง Form; Error เดิมถูกล้างด้วย setErrors(validationErrors) เมื่อข้อมูลผ่านแล้ว
ถ้ายังไม่ส่ง Callback จะเห็นข้อความและข้อมูลที่กรอกยังอยู่ ขั้นถัดไปจะต่อ Callback ที่หน้า Home
Slide 20: ให้ Board เพิ่มรายการใน State
const [items, setItems] = useState<Issue[]>(initialIssues); function handleCreateIssue(issue: Issue) { setItems((currentItems) => [issue, ...currentItems]);}
ส่ง Handler แทน <IssueForm /> เดิมภายใน Main:
<IssueForm onCreateIssue={handleCreateIssue} />
currentItems คือรายการล่าสุด; [issue, ...currentItems] สร้าง Array ใหม่โดยวางรายการใหม่ก่อนรายการเดิม แล้ว List แสดงผลตาม State
Slide 21: ทดสอบเพิ่มรายการที่ Home
- เปิด
/กรอกข้อมูลให้ผ่านทุกเงื่อนไข แล้วกดส่ง - แถวใหม่ต้องอยู่บนสุด มีสถานะ
OPENและจำนวนรายการเพิ่มหนึ่ง - Form ต้องว่าง และกล่อง Error เดิมหายไป
- เพิ่มอีกหนึ่งรายการ แล้วตรวจว่ารายการก่อนหน้ายังอยู่
- Refresh หน้า: รายการที่เพิ่มหาย และกลับมาเป็น Mock Data เดิม
State ช่วยให้หน้าจอเปลี่ยนทันที แต่ยังไม่ใช่การบันทึกข้อมูลถาวร
Slide 22: ข้อจำกัดก่อนต่อ Database
รายการใหม่อยู่เฉพาะใน IssueBoard ของหน้า Home
| จุดที่เปิด | ผลที่คาดไว้ |
|---|---|
/issues | ยังอ่าน Mock Data เดิม ไม่เห็นรายการที่เพิ่งเพิ่ม |
/issues/new | ตรวจข้อมูลได้ แต่แจ้งว่ายังไม่บันทึกและไม่ล้าง Form |
| รายละเอียดของรหัสใหม่ | ไม่พบรายการ เพราะยังค้นจาก data/issue.ts |
| Refresh หรือออกจาก Home แล้วกลับมา | State เริ่มจาก Mock Data ใหม่ |
Day 4 จะเปลี่ยนมาอ่านและบันทึก Database เพื่อให้ทุกหน้าใช้ข้อมูลเดียวกัน
Slide 23: ตรวจฝั่ง Browser แล้ว ยังต้องตรวจฝั่ง Server
การตรวจข้อมูลใน Browser ช่วยให้ผู้ใช้แก้ข้อมูลได้เร็ว แต่ผู้ใช้ยังแก้โค้ดฝั่งตัวเอง หรือส่งข้อมูลตรงไป Server โดยไม่ผ่านฟอร์มได้
ดังนั้น ก่อนบันทึกข้อมูลจริง Server ต้องตรวจข้อมูลซ้ำ แม้ฝั่งหน้าเว็บจะตรวจผ่านมาแล้ว
ลำดับการทำงานตอนนี้: ฟอร์มอ่านและตรวจข้อมูล → Callback ส่ง Issue → Board เปลี่ยน State → List แสดงผล
Hour 4 จะเพิ่มการเปลี่ยน Status ของรายการใน State ชุดเดียวกัน ก่อนเชื่อม Database ใน Day 4