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" เมื่อต้องเก็บข้อมูลที่เปลี่ยนได้หรือรับการกดส่งฟอร์ม
  3. ใช้ useState เก็บรายการปัญหาชั่วคราวใน Browser
  4. อ่านค่าจาก Form ด้วย FormData
  5. ตรวจข้อมูลและแสดงข้อความ Error
  6. เพิ่มปัญหาใหม่ในรายการตัวอย่าง โดยยังไม่ใช้ฐานข้อมูล
  7. อธิบายได้ว่าทำไมการตรวจข้อมูลใน Browser อย่างเดียวยังไม่เพียงพอ

ไฟล์ที่ใช้

components/IssueBoard.tsx  ← สร้างใหม่
components/IssueForm.tsx   ← แก้ไข
app/page.tsx               ← แก้ไข

ไฟล์ที่นำมาใช้ต่อโดยไม่แก้โครงสร้างหลัก:

components/IssueList.tsx
types/issue.ts
data/issue.ts

Slide 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.tsxServer: ส่ง Mock Data เริ่มต้น
IssueBoard.tsxClient: เก็บรายการที่เปลี่ยนได้
IssueForm.tsxClient: รับการส่งฟอร์มและแสดงข้อผิดพลาด

ตอนเปิดหน้า 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 สองรูปแบบ

  1. เว้นช่องว่างแล้วส่ง: Browser จะเตือนจาก required ก่อนเรียก Handler
  2. กรอกชื่อ A, อีเมล a@example.com, หัวข้อ abc, รายละเอียด short แล้วส่ง
  3. ควรเห็นกล่อง Error สามข้อจาก State และข้อมูลใน Form ยังอยู่
  4. แก้ชื่อเป็น An, หัวข้อ เครื่องพิมพ์เสีย, รายละเอียด เครื่องพิมพ์ชั้นสองพิมพ์งานไม่ได้ แล้วส่ง
  5. กล่อง 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

  1. เปิด / กรอกข้อมูลให้ผ่านทุกเงื่อนไข แล้วกดส่ง
  2. แถวใหม่ต้องอยู่บนสุด มีสถานะ OPEN และจำนวนรายการเพิ่มหนึ่ง
  3. Form ต้องว่าง และกล่อง Error เดิมหายไป
  4. เพิ่มอีกหนึ่งรายการ แล้วตรวจว่ารายการก่อนหน้ายังอยู่
  5. 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