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

Day 3 / Hour 3

Form State and Validation

Client state, FormData, validation results, error state, and mock create.

60 minutes
Submitting the form adds a mock issue after validation.

Form State and Validation

Day 3 - ชั่วโมงที่ 3: ทำให้ Form โต้ตอบกับผู้ใช้

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

หลังจบชั่วโมงนี้ นักศึกษาจะสามารถ:

  1. แยกความต่างระหว่าง Server Component และ Client Component ได้
  2. ใช้ "use client" เมื่อต้องใช้ State หรือ Event Handler
  3. ใช้ useState เก็บรายการปัญหาชั่วคราวใน Browser
  4. อ่านค่าจาก Form ด้วย FormData
  5. ตรวจข้อมูลและแสดงข้อความ Error
  6. เพิ่มปัญหาใหม่เข้า Mock List โดยยังไม่ใช้ Database
  7. อธิบายได้ว่าทำไม 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 ComponentClient 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 State

Slide 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 คือรายการเริ่มต้นที่รับมาทาง Props
  • items คือ 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 เมื่อกดปุ่ม Submit
  • event.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 เริ่มต้นเป็น OPEN
  • createdAt เป็นเวลาปัจจุบัน

ในระบบจริง 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 /:

  1. กรอกข้อมูลไม่ครบ แล้วกด Submit
  2. ตรวจว่ามีข้อความ Error และยังไม่มี Row ใหม่
  3. แก้ข้อมูลให้ครบ แล้ว Submit อีกครั้ง
  4. ตรวจว่า Error หาย Form ถูกล้าง และ Row ใหม่อยู่บนสุด
  5. เพิ่มหลายรายการแล้วตรวจว่ารายการเดิมยังอยู่ครบ

ข้อจำกัดของ 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 Component
  • IssueBoard เก็บ State กลางให้ Form และ List ใช้ข้อมูลร่วมกัน
  • FormData อ่านค่าจาก Input โดยใช้ Attribute name
  • Validation แยกข้อมูลที่ผิดก่อนสร้าง Issue
  • State ทำให้ UI เปลี่ยนตามข้อมูลโดยไม่ Reload หน้า
  • Frontend Validation ต้องมี Server Validation ตามมาเสมอ

Hour ถัดไป เราจะทดลองเปลี่ยน Status ใน Mock State ก่อนเตรียมย้ายข้อมูลไป Database


คำศัพท์สำคัญ

คำศัพท์ความหมาย
Server ComponentComponent ที่ Next.js Render ฝั่ง Server เป็นค่าเริ่มต้น
Client ComponentComponent ที่ใช้ State, Event และ Browser API ได้
"use client"คำสั่งประกาศจุดเริ่มต้นของ Client Component
Stateข้อมูลที่เมื่อเปลี่ยนแล้วทำให้ React Render UI ใหม่
Lifting State Upย้าย State ไปไว้ที่ Parent ที่ Component ลูกใช้ร่วมกัน
FormDataAPI สำหรับอ่านค่าจาก Form
Frontend Validationการตรวจข้อมูลใน Browser เพื่อช่วยผู้ใช้