Create with Server Actions
Day 4 - ชั่วโมงที่ 3: บันทึก Issue ด้วย Server Action
เป้าหมายของชั่วโมงนี้
หลังจบชั่วโมงนี้ ผู้เรียนจะสามารถ:
- อธิบายหน้าที่ของ Server Action ได้
- รับและตรวจข้อมูล
FormDataบน Server ได้ - เพิ่ม Issue ใหม่ลง Supabase ได้
- เชื่อม
<form>เข้ากับ Server Action ได้ - แสดงข้อมูลล่าสุดหลังบันทึกสำเร็จได้
ไฟล์ที่ใช้ในชั่วโมงนี้
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 จัดหน้า FormSlide 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:
- Browser รวบรวมค่าจาก Field ที่มี
name - Next.js สร้าง
FormDataแล้วส่งให้createIssueAction - Action ตรวจข้อมูลและเรียก Function สำหรับบันทึก Database
- เมื่อสำเร็จ ระบบพาผู้ใช้ไปหน้ารายการ
ทำไมใช้ 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 สร้างหรือกำหนดให้ |
|---|---|
reporterName | id |
reporterEmail | status |
title | createdAt |
description | updatedAt |
จึงไม่ควรใช้ 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 จากไฟล์นี้ทำงานบน ServerformData.get("reporterName")อ่านค่าจาก Field ที่มีname="reporterName"?? ""ใช้ข้อความว่างเมื่อไม่พบ FieldString(...)ทำให้ค่าที่อ่านได้เป็น 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}`);
}
}ลำดับการทำงาน
- รับข้อมูลที่ผ่าน Validation แล้ว
- เลือก Table
issues .insert({...})เพิ่มข้อมูล 1 Row- แปลงชื่อ Property แบบ
camelCaseให้ตรงกับ Column แบบsnake_case - กำหนด Status เริ่มต้นเป็น
OPEN - ถ้า 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
parseIssueInput()อ่านค่าจาก FormvalidateIssueInput()ตรวจข้อมูล- ถ้ามี Error ให้หยุดก่อนบันทึก
await createIssue(input)รอจน Insert สำเร็จrevalidatePath("/issues")ทำให้หน้ารายการอ่านข้อมูลล่าสุด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และ TypeIssue- Type
NewIssueInputที่ประกาศใน Component getIssueInput(),validateIssueInput()และcreateIssueFormInput()- Type
IssueFormPropsและ ProponCreateIssue - State
errorsและ FunctionhandleSubmit() - กล่อง
{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 3data/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
- เปิดหน้า
/issues/new - ลอง Submit โดยไม่กรอกข้อมูล Browser ควรแจ้งให้กรอก Field ที่จำเป็น
- กรอกข้อมูลให้ผ่านเงื่อนไขแล้วกด
ส่งข้อมูล - ระบบควร Redirect ไปหน้า
/issues - Issue ใหม่ควรอยู่ด้านบนของรายการและมี Status
OPEN - Refresh หน้า
/issuesแล้วข้อมูลต้องยังอยู่ - เปิด 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 Action | Function ฝั่ง Server ที่ Form เรียกได้ |
FormData | Object ที่เก็บค่าจาก Form ตามชื่อ name |
| Server Validation | การตรวจข้อมูลบน Server ก่อนบันทึก |
.insert() | เพิ่ม Row ใหม่ลง Table |
revalidatePath() | ทำให้ Path ที่กำหนดอ่านข้อมูลล่าสุด |
redirect() | พาผู้ใช้ไปยัง URL อื่นหลังทำงานเสร็จ |