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

Day 4 / Hour 3

Create with Server Actions

Server Actions, server-side validation, createIssue, and form integration.

60 minutes
The app creates issues in Supabase.

Create with Server Actions

Day 4 - ชั่วโมงที่ 3: บันทึก Issue ด้วย Server Action

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

หลังจบชั่วโมงนี้ ผู้เรียนจะสามารถ:

  1. อธิบายหน้าที่ของ Server Action ได้
  2. รับและตรวจข้อมูล FormData บน Server ได้
  3. เพิ่ม Issue ใหม่ลง Supabase ได้
  4. เชื่อม <form> เข้ากับ Server Action ได้
  5. แสดงข้อมูลล่าสุดหลังบันทึกสำเร็จได้

ไฟล์ที่ใช้ในชั่วโมงนี้

types/issue.ts              เพิ่ม Type สำหรับข้อมูลจาก Form
lib/issues.ts               เพิ่ม Function สำหรับ Insert
app/actions.ts              สร้าง Server Action
components/IssueForm.tsx    เปลี่ยนจาก Client Submit เป็น Form Action
app/issues/new/page.tsx     จัดหน้า Form

Slide 1: จาก Read ไปสู่ Create

Hour 2 ทำให้ระบบอ่านข้อมูลจริงจาก Supabase ได้แล้ว:

Supabase → getIssues() → /issues
Supabase → getIssueById() → /issues/[id]

แต่ Form จาก Day 3 ยังเพิ่มข้อมูลลง State ใน Browser เท่านั้น เมื่อ Refresh ข้อมูลจึงหาย

Hour นี้เราจะเปลี่ยน Create Flow เป็น:

IssueForm
  → Server Action
  → ตรวจข้อมูลบน Server
  → Insert ลง Supabase
  → กลับไปหน้า /issues

หลังเปลี่ยนแล้ว Supabase จะเป็น Source of Truth ทั้งการอ่านและการสร้าง Issue


Slide 2: Server Action คืออะไร

Server Action คือ async function ที่ทำงานบน Server และสามารถรับข้อมูลจาก <form> ได้โดยตรง

<form action={createIssueAction}>
  {/* fields */}
</form>

เมื่อผู้ใช้กด Submit:

  1. Browser รวบรวมค่าจาก Field ที่มี name
  2. Next.js สร้าง FormData แล้วส่งให้ createIssueAction
  3. Action ตรวจข้อมูลและเรียก Function สำหรับบันทึก Database
  4. เมื่อสำเร็จ ระบบพาผู้ใช้ไปหน้ารายการ

ทำไมใช้ Server Action

  • Form ติดต่อ Logic ฝั่ง Server ได้โดยไม่ต้องสร้าง API Route เพิ่ม
  • Code สำหรับบันทึก Database ไม่ต้องทำงานใน Browser
  • เราสามารถตรวจข้อมูลซ้ำก่อนบันทึกจริงได้

Server Action ไม่ได้ทำให้ข้อมูลปลอดภัยโดยอัตโนมัติ ทุก Action ยังต้องตรวจ Input และภายหลังต้องตรวจสิทธิ์ผู้ใช้งานด้วย


Slide 3: สร้าง Type สำหรับข้อมูลจาก Form

แก้บางส่วนใน types/issue.ts โดยเพิ่ม Type นี้ต่อจาก Issue:

export type NewIssueInput = {
  reporterName: string;
  reporterEmail: string;
  title: string;
  description: string;
};

NewIssueInput มีเฉพาะค่าที่ผู้ใช้ต้องกรอก ส่วน Issue คือข้อมูลที่บันทึกสมบูรณ์แล้ว

ผู้ใช้กรอกDatabase สร้างหรือกำหนดให้
reporterNameid
reporterEmailstatus
titlecreatedAt
descriptionupdatedAt

จึงไม่ควรใช้ Issue เป็น Type ของข้อมูลจาก Form เพราะตอน Submit เรายังไม่มีข้อมูลครบทุก Property


Slide 4: อ่านค่าจาก FormData

สร้างไฟล์ใหม่ app/actions.ts แล้วเริ่มด้วย Code นี้:

"use server";
 
import type { NewIssueInput } from "@/types/issue";
 
function parseIssueInput(formData: FormData): NewIssueInput {
  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(),
  };
}
  • "use server" ต้องอยู่ก่อน Import เพื่อระบุว่า Function ที่ Export จากไฟล์นี้ทำงานบน Server
  • formData.get("reporterName") อ่านค่าจาก Field ที่มี name="reporterName"
  • ?? "" ใช้ข้อความว่างเมื่อไม่พบ Field
  • String(...) ทำให้ค่าที่อ่านได้เป็น String
  • .trim() ตัดช่องว่างด้านหน้าและด้านหลัง

ชื่อใน formData.get(...) ต้องตรงกับ name ของ Input ทุกตัว ไม่เช่นนั้นค่าที่อ่านได้จะเป็นข้อความว่าง


Slide 5: ตรวจข้อมูลบน Server

เพิ่ม Function นี้ต่อจาก parseIssueInput() ใน app/actions.ts:

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 นี้ตรวจครบทุก Field แล้วคืน Array ของข้อความ Error:

[]                     → ข้อมูลผ่าน
["กรุณากรอก..."]       → ข้อมูลยังไม่ผ่าน

แม้ Input จะมี required, type="email" หรือ minLength ใน HTML เราก็ยังตรวจซ้ำบน Server เพราะ Request สามารถถูกสร้างขึ้นโดยไม่ผ่านหน้า Form ได้


Slide 6: สร้าง createIssue() สำหรับ Insert

แก้บางส่วนใน lib/issues.ts

เพิ่ม NewIssueInput เข้าไปใน Type Import เดิม:

import type { Issue, IssueStatus, NewIssueInput } from "@/types/issue";

จากนั้นเพิ่ม Function นี้ต่อจาก getIssueById():

export async function createIssue(
  input: NewIssueInput,
): Promise<void> {
  const supabase = createSupabaseServerClient();
 
  const { error } = await supabase.from("issues").insert({
    reporter_name: input.reporterName,
    reporter_email: input.reporterEmail,
    title: input.title,
    description: input.description,
    status: "OPEN",
  });
 
  if (error) {
    throw new Error(`Failed to create issue: ${error.message}`);
  }
}

ลำดับการทำงาน

  1. รับข้อมูลที่ผ่าน Validation แล้ว
  2. เลือก Table issues
  3. .insert({...}) เพิ่มข้อมูล 1 Row
  4. แปลงชื่อ Property แบบ camelCase ให้ตรงกับ Column แบบ snake_case
  5. กำหนด Status เริ่มต้นเป็น OPEN
  6. ถ้า Insert ล้มเหลว ให้หยุดและแจ้ง Error

เราอ่านเฉพาะ error เพราะ Flow นี้ยังไม่ต้องใช้ Row ที่เพิ่งสร้างกลับมา


Slide 7: สร้าง createIssueAction()

เพิ่ม Import เหล่านี้ด้านบน app/actions.ts ต่อจาก Type Import:

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createIssue } from "@/lib/issues";

จากนั้นเพิ่ม Server Action ต่อจาก validateIssueInput():

export async function createIssueAction(formData: FormData) {
  const input = parseIssueInput(formData);
  const errors = validateIssueInput(input);
 
  if (errors.length > 0) {
    throw new Error(errors.join(", "));
  }
 
  await createIssue(input);
 
  revalidatePath("/issues");
  redirect("/issues");
}

Action นี้ควบคุมทั้ง Create Flow

  1. parseIssueInput() อ่านค่าจาก Form
  2. validateIssueInput() ตรวจข้อมูล
  3. ถ้ามี Error ให้หยุดก่อนบันทึก
  4. await createIssue(input) รอจน Insert สำเร็จ
  5. revalidatePath("/issues") ทำให้หน้ารายการอ่านข้อมูลล่าสุด
  6. redirect("/issues") พาผู้ใช้ไปดูรายการ

ต้องเรียก revalidatePath() ก่อน redirect() เพราะ redirect() จะจบการทำงานของ Action ทันที


Slide 8: เปลี่ยน IssueForm ให้เรียก Server Action

เทียบกับ Code ปัจจุบัน

เพิ่ม Import ใหม่ไว้บนสุดของไฟล์ เพื่อให้ Form เรียก Server Action ที่สร้างใน Slide 7:

import { createIssueAction } from "@/app/actions";

ลบ Client Logic ที่ย้ายไปทำงานใน app/actions.ts และ Supabase แล้ว:

  • "use client"
  • useState และ Type Issue
  • Type NewIssueInput ที่ประกาศใน Component
  • getIssueInput(), validateIssueInput() และ createIssueFormInput()
  • Type IssueFormProps และ Prop onCreateIssue
  • State errors และ Function handleSubmit()
  • กล่อง {errors.length > 0 && (...)}

แก้จุดเชื่อม Form:

// เดิม: ทำงานใน Browser
<form className="mt-4" onSubmit={handleSubmit}>
 
// ใหม่: ส่ง FormData ไป Server Action
<form action={createIssueAction} className="mt-6">

ลบ <section> และ <h2> ที่ครอบ Form ออก เพราะ Slide 9 จะให้ app/issues/new/page.tsx รับผิดชอบ Card และหัวข้อของหน้าแทน

Field เดิมยังใช้ต่อ แต่ให้ปรับเพิ่มเล็กน้อย:

  • reporterEmail เปลี่ยนจาก type="text" เป็น type="email"
  • เพิ่ม minLength={2} ให้ชื่อผู้แจ้ง
  • เพิ่ม minLength={5} ให้หัวข้อ
  • เพิ่ม minLength={10} และ resize-y ให้รายละเอียด
  • เก็บ id, name, required, Label และ Tailwind Class เดิมไว้

ใช้ Code นี้แทนเนื้อหาทั้งไฟล์ components/IssueForm.tsx:

import { createIssueAction } from "@/app/actions";
 
export function IssueForm() {
  return (
    <form action={createIssueAction} className="mt-6">
      <fieldset className="grid gap-5">
        <legend className="text-sm font-semibold text-slate-700">
          ข้อมูลปัญหา
        </legend>
 
        <div className="grid gap-4 md:grid-cols-2">
          <div className="grid gap-2">
            <label
              htmlFor="reporterName"
              className="text-sm font-semibold text-slate-800"
            >
              ชื่อผู้แจ้ง
            </label>
            <input
              id="reporterName"
              name="reporterName"
              type="text"
              required
              minLength={2}
              className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-teal-700 focus:outline-none focus:ring-4 focus:ring-teal-100"
            />
          </div>
 
          <div className="grid gap-2">
            <label
              htmlFor="reporterEmail"
              className="text-sm font-semibold text-slate-800"
            >
              อีเมลผู้แจ้ง
            </label>
            <input
              id="reporterEmail"
              name="reporterEmail"
              type="email"
              required
              className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-teal-700 focus:outline-none focus:ring-4 focus:ring-teal-100"
            />
          </div>
        </div>
 
        <div className="grid gap-2">
          <label
            htmlFor="title"
            className="text-sm font-semibold text-slate-800"
          >
            หัวข้อปัญหา
          </label>
          <input
            id="title"
            name="title"
            type="text"
            required
            minLength={5}
            className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-teal-700 focus:outline-none focus:ring-4 focus:ring-teal-100"
          />
        </div>
 
        <div className="grid gap-2">
          <label
            htmlFor="description"
            className="text-sm font-semibold text-slate-800"
          >
            รายละเอียดปัญหา
          </label>
          <textarea
            id="description"
            name="description"
            rows={5}
            required
            minLength={10}
            className="w-full resize-y rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-teal-700 focus:outline-none focus:ring-4 focus:ring-teal-100"
          />
        </div>
 
        <button
          type="submit"
          className="rounded-md bg-teal-700 px-4 py-3 font-bold text-white hover:bg-teal-800 focus:outline-none focus:ring-4 focus:ring-teal-100"
        >
          ส่งข้อมูล
        </button>
      </fieldset>
    </form>
  );
}

สิ่งที่เปลี่ยนจาก Day 3

  • Form ไม่สร้าง Issue และไม่เพิ่ม State ใน Browser แล้ว
  • action={createIssueAction} ส่งข้อมูลไปทำงานบน Server
  • Browser ตรวจข้อมูลเบื้องต้นจาก required, type และ minLength
  • Server Action ตรวจข้อมูลซ้ำก่อนบันทึกลง Supabase

หลังลบ State และ Event Handler แล้ว Component นี้ไม่จำเป็นต้องเป็น Client Component


Slide 9: แก้ Error จาก IssueBoard

IssueBoard เคยทำหน้าที่อะไร

ใน Day 3 เรายังไม่มี Database จึงให้ IssueBoard เป็น Parent Component ที่เก็บรายการไว้ใน State กลาง:

IssueForm ส่ง Issue ใหม่

IssueBoard เพิ่มหรือแก้ State

IssueList แสดงรายการล่าสุด

ขั้นตอนนี้ทำให้เราได้ฝึก useState, Props, Event Handler และการแบ่งความรับผิดชอบระหว่าง Component แม้ข้อมูลจะยังหายเมื่อ Refresh

ตอนนี้หน้าที่เหล่านั้นถูกย้ายแล้ว:

IssueBoard State  → Supabase Database
Client Handler    → Server Action
Mock Data         → getIssues()

ดังนั้น IssueBoard ไม่ได้เป็น Code ที่สร้างโดยไม่มีประโยชน์ แต่เป็น Prototype สำหรับเรียนรู้ Client State ก่อนเปลี่ยนระบบให้บันทึกข้อมูลจริง

หลัง Slide 8 IssueForm ไม่รับ Prop onCreateIssue แล้ว แต่ IssueBoard จาก Day 3 ยังมี Code เดิม:

<IssueForm onCreateIssue={handleCreateIssue} />

TypeScript จึงขีดเส้นใต้ onCreateIssue เพราะ Prop นี้ไม่มีอยู่ใน IssueForm เวอร์ชันใหม่

เราไม่ต้องแก้ Prop ให้ผ่าน เพราะหลัง Hour 2 หน้า Home และหน้า /issues ไม่ได้ใช้ IssueBoard หรือ Mock Data แล้ว ให้ลบไฟล์เก่าที่ไม่ถูก Import ทั้งสองไฟล์ออก:

components/IssueBoard.tsx
data/issue.ts

ทำไมควรลบ

  • IssueBoard เก็บ State และ Handler ของ Create/Update แบบทดลองจาก Day 3
  • data/issue.ts เก็บ Mock Data ที่ถูกแทนด้วยข้อมูลจาก Supabase แล้ว
  • การเก็บ Code เก่าทำให้สับสนว่า Source of Truth อยู่ที่ State หรือ Database
  • TypeScript ยังตรวจไฟล์ที่ไม่ถูกใช้งานและอาจแจ้ง Error เมื่อ Props เปลี่ยน

ก่อนลบ ให้ใช้ Search ตรวจว่าไม่มีไฟล์ใด Import IssueBoard หรือ @/data/issue เหลืออยู่

หลังลบ ไฟล์ IssueForm.tsx ไม่ต้องรับ onCreateIssue และ Error ในภาพจะหายไป


Slide 10: จัดหน้า /issues/new

ใช้ Code นี้แทนเนื้อหาทั้งไฟล์ app/issues/new/page.tsx:

import { IssueForm } from "@/components/IssueForm";
 
export default function NewIssuePage() {
  return (
    <main className="mx-auto max-w-3xl px-6 py-8">
      <section className="rounded-lg border border-slate-200 bg-white p-6">
        <h1 className="text-2xl font-bold text-slate-950">
          แจ้งปัญหาใหม่
        </h1>
        <p className="mt-2 text-sm text-slate-600">
          กรอกข้อมูลปัญหาที่ต้องการให้ฝ่าย IT ตรวจสอบ
        </p>
        <IssueForm />
      </section>
    </main>
  );
}

แต่ละไฟล์แบ่งหน้าที่กันดังนี้:

page.tsx       จัดโครงสร้างของหน้า
IssueForm.tsx  แสดง Field และส่ง FormData
actions.ts     อ่านและตรวจข้อมูล
issues.ts      ติดต่อ Supabase

หน้า NewIssuePage และ IssueForm ไม่ต้องรับ onCreateIssue เพราะข้อมูลใหม่จะถูกเก็บใน Database ไม่ใช่ State ของ Parent Component


Slide 11: ทดสอบ Create Flow

  1. เปิดหน้า /issues/new
  2. ลอง Submit โดยไม่กรอกข้อมูล Browser ควรแจ้งให้กรอก Field ที่จำเป็น
  3. กรอกข้อมูลให้ผ่านเงื่อนไขแล้วกด ส่งข้อมูล
  4. ระบบควร Redirect ไปหน้า /issues
  5. Issue ใหม่ควรอยู่ด้านบนของรายการและมี Status OPEN
  6. Refresh หน้า /issues แล้วข้อมูลต้องยังอยู่
  7. เปิด Supabase Table Editor และตรวจว่ามี Row ใหม่

ถ้าสร้างไม่สำเร็จ

  • กดแล้วไม่เรียก Action: ตรวจว่า Form ใช้ action={createIssueAction}
  • Field กลายเป็นข้อความว่าง: ตรวจ name ให้ตรงกับ formData.get(...)
  • เห็น permission denied: ตรวจ GRANT INSERT และ RLS Policy จาก Hour 1
  • Redirect แล้วไม่เห็นรายการใหม่: ตรวจ revalidatePath("/issues") และ getIssues()
  • TypeScript Error จาก IssueBoard: ตรวจว่าได้ลบ Mock Flow ตาม Slide 10 แล้ว

ถ้า Server Validation ไม่ผ่านใน Development อาจเห็น Error Page เพราะ Action ใช้ throw new Error(...) ข้อมูลจะไม่ถูกบันทึก แต่การนำ Error กลับมาแสดงข้าง Form ด้วย useActionState เป็นหัวข้อต่อยอดที่ยังไม่จำเป็นใน Flow นี้


Slide 12: สรุป Create Flow

IssueForm
  → createIssueAction(formData)
  → parseIssueInput()
  → validateIssueInput()
  → createIssue(input)
  → Supabase INSERT
  → revalidatePath("/issues")
  → redirect("/issues")

ตอนนี้ระบบทำอะไรได้แล้ว

  • หน้า /issues อ่านข้อมูลจริงจาก Supabase
  • หน้า /issues/[id] อ่าน Issue ตาม id
  • หน้า /issues/new สร้าง Issue ผ่าน Server Action
  • ข้อมูลยังอยู่หลัง Refresh เพราะ Database เป็น Source of Truth

Hour ถัดไปเราจะเปลี่ยนปุ่ม Status ให้ Update ข้อมูลใน Supabase และเตรียม Project สำหรับ Deploy


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

คำศัพท์ความหมาย
Server ActionFunction ฝั่ง Server ที่ Form เรียกได้
FormDataObject ที่เก็บค่าจาก Form ตามชื่อ name
Server Validationการตรวจข้อมูลบน Server ก่อนบันทึก
.insert()เพิ่ม Row ใหม่ลง Table
revalidatePath()ทำให้ Path ที่กำหนดอ่านข้อมูลล่าสุด
redirect()พาผู้ใช้ไปยัง URL อื่นหลังทำงานเสร็จ

อ้างอิง