FE Day 21 表單與 Server Actions
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第二十一篇。前二十天你學會了元件、狀態、Hook、樣式、App Router、Server/Client Component,今天進入「表單」這個 React 開發最日常的工作。傳統寫法是「onSubmit + fetch + useState 管理送出狀態」一條龍;Next.js 14 起的 Server Actions 把這條鏈簡化成「<form action={fn}>,fn 直接在伺服器執行」。今天會從「表單設計的全貌」講到「Server Actions 的標準寫法」再到「useActionState、useFormStatus 的搭配」,讓你能在 booking-fe 上把「新增預約」的流程從 80 行 Client 程式碼縮短到 20 行。讀完之後你應該能寫出「有驗證、有錯誤訊息、有 optimistic UI」的標準 Next.js 表單。
引言
寫後端的工程師對「表單送出」並不陌生:瀏覽器送 HTTP POST、後端讀 form data、驗證、寫資料庫、回傳結果。React 時代把這條鏈拆得更細:onsubmit 攔截、表單資料序列化、fetch 送出、response 解析、UI 更新。每一段都要寫、每一段都可能出錯。Server Actions 的設計是把這條鏈「合併到框架」:框架自動幫你序列化表單、自動送 POST、自動 parse response、自動處理 revalidate。
今天的重點有四個。第一,傳統 React 表單的問題點(為什麼我們需要 Server Actions);第二,Server Actions 的標準寫法:"use server"、<form action={fn}>、fn 在伺服器執行;第三,useActionState 處理表單狀態(驗證錯誤、欄位錯誤、pending 狀態);第四,搭配 useFormStatus 寫出「送出中」的按鈕。我們會用 booking 預約系統的「新增預約」做完整範例,從表單欄位、伺服器驗證、錯誤訊息、optimistic UI 一條龍走完。
對照後端的經驗,Server Actions 像「Django 的 form view + DRF 的 serializer」合體——瀏覽器送來 form data,伺服器端函式接收、驗證、執行業務邏輯、回傳結果,框架自動更新 UI。差別是「不用寫 API 端點」「不用寫 client fetch」「不用處理 CSRF(框架內建)」,省下的程式碼非常可觀。
傳統 React 表單的問題
先看「沒有 Server Actions 之前」要怎麼寫一個新增預約的表單。這段大概要 50–80 行程式碼:
// 傳統寫法:Client 表單 + fetch
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type FormState = { customerName: string; date: string; time: string };
type Errors = Partial<Record<keyof FormState, string>>;
export function NewBookingForm() {
const router = useRouter();
const [form, setForm] = useState<FormState>({ customerName: "", date: "", time: "" });
const [errors, setErrors] = useState<Errors>({});
const [pending, setPending] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
function validate(values: FormState): Errors {
const e: Errors = {};
if (!values.customerName.trim()) e.customerName = "請輸入姓名";
if (!values.date) e.date = "請選擇日期";
if (!values.time) e.time = "請選擇時段";
return e;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const v = validate(form);
setErrors(v);
if (Object.keys(v).length > 0) return;
setPending(true);
setServerError(null);
try {
const res = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
const body = await res.json();
setServerError(body.message ?? `送出失敗(${res.status})`);
return;
}
router.push("/bookings");
router.refresh();
} catch (e) {
setServerError(e instanceof Error ? e.message : "送出失敗");
} finally {
setPending(false);
}
}
// 還要寫 input + label + error message…
}
這段程式碼有幾個痛點。第一,整支檔案必須是 client("use client"),因為有 useState 與事件處理;第二,驗證邏輯、錯誤狀態、送出狀態全部用 useState 管理,狀態一多就難維護;第三,要另外寫 API 端點(/api/bookings),相當於把後端的 endpoint 再寫一次;第四,CSRF、cookie、headers 都要自己處理。
Server Actions 把這四個痛點一次解決:表單送出直接呼叫伺服器函式,狀態由框架管理,沒有 API 端點(函式本身就是 endpoint),CSRF 由框架處理。我們下面就來看具體寫法。
Server Actions 的標準寫法
Server Action 是一支「在伺服器執行、非同步」的函式。在函式本體頂端加 "use server",框架就保證這個函式永遠不會被打包進 client bundle,前端只能呼叫、看不到內容:
// src/features/booking/actions.ts
// Server Actions:新增預約
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { createBooking } from "./api";
// 用 zod 做 schema 驗證
const NewBookingSchema = z.object({
customerName: z.string().min(1, "請輸入姓名").max(50),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "日期格式錯誤"),
time: z.string().regex(/^\d{2}:\d{2}$/, "時段格式錯誤"),
note: z.string().max(500).optional(),
});
export type NewBookingInput = z.infer<typeof NewBookingSchema>;
// Action 的回傳型別:成功 / 驗證錯誤 / 伺服器錯誤
export type NewBookingResult =
| { ok: true; id: string }
| { ok: false; fieldErrors: Partial<Record<keyof NewBookingInput, string>>; formError?: undefined }
| { ok: false; fieldErrors?: undefined; formError: string };
export async function createBookingAction(
_prev: NewBookingResult | null,
formData: FormData,
): Promise<NewBookingResult> {
// 把 FormData 轉成物件並驗證
const raw = {
customerName: formData.get("customerName")?.toString() ?? "",
date: formData.get("date")?.toString() ?? "",
time: formData.get("time")?.toString() ?? "",
note: formData.get("note")?.toString() ?? "",
};
const parsed = NewBookingSchema.safeParse(raw);
if (!parsed.success) {
const fieldErrors: Partial<Record<keyof NewBookingInput, string>> = {};
for (const issue of parsed.error.issues) {
const key = issue.path[0] as keyof NewBookingInput | undefined;
if (key && !fieldErrors[key]) fieldErrors[key] = issue.message;
}
return { ok: false, fieldErrors };
}
try {
const id = await createBooking(parsed.data); // 打後端 API
revalidatePath("/bookings"); // 讓 /bookings 重新讀資料
redirect(`/bookings/${id}`); // 跳轉到詳情頁
} catch (e) {
return {
ok: false,
formError: e instanceof Error ? e.message : "新增失敗,請稍後重試",
};
}
// redirect 會丟出特殊錯誤讓框架處理,這行不會跑到
return { ok: true, id: "" };
}
這支函式把所有「表單送出後要做的事」集中在一處:讀 FormData、用 zod 驗證、呼叫後端 API、revalidate 清單頁、跳轉詳情頁。注意幾個關鍵設計:
FormData 直接接收:瀏覽器送來的表單資料是 FormData 物件(瀏覽器原生支援),不需要手動序列化或 parse。
zod 驗證:把 schema 集中定義,能在 client(送出前預覽)與 server(最終驗證)共用,避免「前端說沒問題、後端說有問題」的不一致。
回傳結構化結果:用 discriminated union({ ok: true } vs { ok: false, fieldErrors } vs { ok: false, formError })表達三種結果,TypeScript 編譯器會強制處理每個分支。
revalidatePath:告訴框架「/bookings 這條路由的資料已經過期、重新抓」;下次有人存取 /bookings 就會看到剛剛新增的預約。這對 Server Component 的快取模型特別關鍵——沒有 revalidate 的話,新增的預約不會出現在清單。
redirect:送出成功後跳轉到詳情頁。注意 redirect 內部會丟出特殊的 Next.js 錯誤,TypeScript 的「unreachable code」警告是預期的——函式不會真的跑到 return 那行。
用 useActionState 把 action 接到 form
useActionState 是 React 19 的新 Hook,專門處理「action + 狀態」的搭配。它接收一支 action 函式與初始 state,回傳「目前 state、要給 form 的 action、pending 旗標」:
// src/features/booking/components/NewBookingForm.tsx
// Client 表單:搭配 Server Action 與 useActionState
"use client";
import { useActionState } from "react";
import { createBookingAction, type NewBookingResult } from "../actions";
const initial: NewBookingResult | null = null;
export function NewBookingForm() {
const [state, formAction, pending] = useActionState(createBookingAction, initial);
return (
<form action={formAction} className="space-y-4" aria-busy={pending}>
<div>
<label htmlFor="customerName" className="block text-sm font-medium">
姓名
</label>
<input
id="customerName"
name="customerName"
required
className="mt-1 w-full rounded border px-2 py-1"
aria-invalid={state?.ok === false && !!state.fieldErrors?.customerName}
aria-describedby={state?.fieldErrors?.customerName ? "name-error" : undefined}
/>
{state?.ok === false && state.fieldErrors?.customerName && (
<p id="name-error" role="alert" className="mt-1 text-sm text-red-700">
{state.fieldErrors.customerName}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="date" className="block text-sm font-medium">日期</label>
<input id="date" name="date" type="date" required className="mt-1 w-full rounded border px-2 py-1" />
{state?.fieldErrors?.date && (
<p role="alert" className="mt-1 text-sm text-red-700">{state.fieldErrors.date}</p>
)}
</div>
<div>
<label htmlFor="time" className="block text-sm font-medium">時段</label>
<input id="time" name="time" type="time" required className="mt-1 w-full rounded border px-2 py-1" />
{state?.fieldErrors?.time && (
<p role="alert" className="mt-1 text-sm text-red-700">{state.fieldErrors.time}</p>
)}
</div>
</div>
<div>
<label htmlFor="note" className="block text-sm font-medium">備註</label>
<textarea id="note" name="note" rows={3} className="mt-1 w-full rounded border px-2 py-1" />
</div>
{state?.formError && (
<p role="alert" className="text-sm text-red-700">{state.formError}</p>
)}
<button
type="submit"
disabled={pending}
className="rounded bg-slate-900 px-4 py-2 text-white disabled:bg-slate-400"
>
{pending ? "送出中…" : "新增預約"}
</button>
</form>
);
}
整段表單的設計重點:<form action={formAction}> 把 form 接到 useActionState 回傳的 action,瀏覽器送出表單時框架自動呼叫伺服器函式;表單欄位用 name="..." 對應 FormData 的 key;錯誤訊息用 role="alert" 讓螢幕閱讀器自動朗讀;aria-invalid 標記錯誤欄位、aria-describedby 把錯誤訊息跟欄位綁在一起。
特別注意:useActionState 是 client hook,但 action 函式本身是 server。表單提交時,瀏覽器送 POST 到一個特殊的 RPC endpoint(由 Next.js 自動產生),伺服器執行 action、回傳新的 state、瀏覽器重新 render。整個流程在「Server Action」這個抽象底下,看起來就像函式直接被呼叫一樣,但實際上是「client 序列化 → POST → server 執行 → 回傳結果 → client 更新 state」。
useFormStatus 與表單按鈕
送出按鈕的「送出中」狀態除了用 useActionState 的 pending 旗標,也可以用 useFormStatus。差別是 useFormStatus 必須在 <form> 內的元件使用(透過 React Context 讀 form 狀態),適合「按鈕需要獨立檔案」的情境:
// src/components/form/SubmitButton.tsx
// 共用送出按鈕:搭配 useFormStatus
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ children, pendingLabel }: { children: React.ReactNode; pendingLabel?: string }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="rounded bg-slate-900 px-4 py-2 text-white disabled:bg-slate-400"
>
{pending ? (pendingLabel ?? "送出中…") : children}
</button>
);
}
把這個按鈕放在表單裡,框架會自動把 form 的 pending 狀態傳進來。實務上,「表單 + 按鈕在同一支檔案」用 useActionState 的 pending 即可;「表單與按鈕拆檔」用 useFormStatus,可以讓按鈕元件獨立測試、重用。
漸進增強:沒 JavaScript 也能用
Server Action 最大的隱藏好處是「沒 JavaScript 也能跑」。當瀏覽器關閉 JavaScript(或搜尋引擎爬蟲、純文字瀏覽器、Lynx)時,<form action={action}> 會退化成標準的 HTML form submit:瀏覽器把 form data 包成 application/x-www-form-urlencoded、送到伺服器、伺服器執行 action、回傳新的 HTML。這就是「progressive enhancement(漸進增強)」——JS 跑得起來時享受 SPA 體驗,跑不起來時至少有基本功能。
這個特性對 SEO 與無障礙特別重要。許多企業內部系統、行動裝置使用者、低速網路環境可能會關閉 JavaScript,Server Action 讓這些情境都能使用表單。寫法上不需特別處理,只要 <form> 是用「name 對應 FormData key」的標準欄位設計就行——不要用 onChange 動態塞欄位、不要靠 useState 控制欄位。
// src/app/bookings/new/page.tsx
// 漸進增強:把表單放在 Server Component 內即可
import { NewBookingForm } from "@/features/booking/components/NewBookingForm";
export default function NewBookingPage() {
return (
<main className="mx-auto max-w-2xl px-6 py-12">
<h1 className="text-2xl font-bold">新增預約</h1>
<p className="mt-2 text-slate-600">填寫以下欄位,我們會在 24 小時內確認。</p>
<div className="mt-8">
<NewBookingForm />
</div>
</main>
);
}
注意 page.tsx 本身是 Server Component——不需要 "use client",因為它只負責組裝版面。互動的 NewBookingForm 才標 client。這種「Server 殼 + Client 表單」的結構是 App Router 的標準組合,也是 bundle size 最小的寫法。
搭配 useOptimistic 寫「送出中馬上看到結果」
有些表單送出後,使用者期待「立刻看到結果」而不是「送出中…」。useOptimistic(昨天介紹過)搭配 Server Action 可以做到這點——按下「刪除」按鈕的瞬間,UI 顯示「已刪除」,伺服器確認後實際刪除:
// src/features/booking/components/DeleteBookingButton.tsx
// 樂觀刪除:搭配 Server Action
"use client";
import { useOptimistic, useTransition } from "react";
import { deleteBookingAction } from "../actions";
type Booking = { id: string; title: string };
export function DeleteBookingButton({ booking }: { booking: Booking }) {
const [optimistic, setOptimistic] = useOptimistic(booking);
const [, startTransition] = useTransition();
function handleClick() {
if (!confirm(`確定刪除「${booking.title}」嗎?`)) return;
startTransition(async () => {
setOptimistic({ id: booking.id, title: `刪除中…` });
await deleteBookingAction(booking.id);
// 成功:框架自動重新 render,optimistic 會跟實際狀態對齊
});
}
return (
<button
type="button"
onClick={handleClick}
className="rounded border border-red-300 px-2 py-1 text-red-700 hover:bg-red-50"
>
{optimistic.title === booking.title ? "刪除" : optimistic.title}
</button>
);
}
這段寫法的關鍵是「樂觀狀態由 useOptimistic 管理、實際狀態由伺服器決定」。按下按鈕時 setOptimistic({ ..., title: "刪除中…" }) 立即更新畫面;Server Action 執行完、revalidate 完成、Server Component 重新 render,新的 booking 不再包含這筆資料,useOptimistic 自動回到「無對應實際狀態」並把整個元件從畫面移除。
測試 Server Actions
Server Actions 雖然是 React/Next.js 的新概念,但測試起來跟一般 async 函式一樣:用 Vitest 把 action 當作普通函式呼叫、檢查回傳結果。例如測試上面寫的 createBookingAction:
// src/features/booking/actions.test.ts
// 用 Vitest 測試 Server Action
import { describe, it, expect, vi } from "vitest";
import { createBookingAction } from "./actions";
// mock 後端 API
vi.mock("./api", () => ({
createBooking: vi.fn(),
}));
import { createBooking } from "./api";
describe("createBookingAction", () => {
it("驗證失敗時回傳 fieldErrors", async () => {
const formData = new FormData();
formData.set("customerName", "");
formData.set("date", "bad-date");
formData.set("time", "");
const result = await createBookingAction(null, formData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.fieldErrors?.customerName).toBeDefined();
expect(result.fieldErrors?.date).toBeDefined();
expect(result.fieldErrors?.time).toBeDefined();
}
});
it("驗證通過時呼叫 createBooking", async () => {
vi.mocked(createBooking).mockResolvedValue("new-id");
const formData = new FormData();
formData.set("customerName", "王小明");
formData.set("date", "2026-04-15");
formData.set("time", "14:30");
// redirect 會丟出特殊錯誤;用 vi.mocked 把 next/navigation 的 redirect 變成拋錯
await expect(createBookingAction(null, formData)).rejects.toThrow();
expect(createBooking).toHaveBeenCalledWith({
customerName: "王小明",
date: "2026-04-15",
time: "14:30",
note: "",
});
});
});
測試 Server Action 的兩個關鍵:第一,用 vi.mock("./api") 把後端 API mock 掉,不打真的網路。第二,redirect 會丟出特殊錯誤,測試時用 rejects.toThrow() 確認有呼叫到 redirect;或者把 next/navigation 的 redirect 也 mock 掉、改成回傳特定值。完整測試覆蓋率(包含成功、驗證失敗、伺服器失敗三條路徑)是 Day 15 之後的標準練習。
送出後的細節:CSRF、檔案上傳、二進位資料
Server Action 對常見的 HTTP 表單欄位(文字、數字、checkbox、radio、select、textarea)都內建支援。對一些進階欄位也有一些細節要注意。第一,檔案上傳:表單用 multipart/form-data 編碼,action 可以直接讀 formData.get("avatar") 拿到 File 物件,再 await file.arrayBuffer() 取得二進位內容、寫到 S3 或本機磁碟。
第二,多個 submit button:同一個 form 內有多顆按鈕各自觸發不同 action,可以用 formAction prop:
// 一個 form 兩顆按鈕
<form action={createBookingAction}>
<input name="customerName" />
<button type="submit" formAction={saveDraftAction}>存草稿</button>
<button type="submit">送出預約</button>
</form>
第三,巢狀 formData:action 內可以呼叫其他 server action 來組合多個動作——例如「建立訂單 action」內呼叫「扣庫存 action」、「寄通知 action」。巢狀呼叫仍然是非同步的、錯誤會向上傳遞、整個鏈只要有一個失敗就視為失敗。
實戰組合:管理者審核預約的審核流程
把 Server Actions 用在更複雜的場景——「管理者審核預約」。這個流程有三個動作:通過、退件、轉指派。三個動作都應該走 Server Action,並各自有權限檢查。我們用一個審核工具列展示:
// src/features/booking/components/ReviewActions.tsx
// 管理者審核工具列:三個動作都是 Server Action
"use client";
import { useActionState } from "react";
import { approveBooking, rejectBooking, reassignBooking } from "../actions";
export function ReviewActions({ bookingId }: { bookingId: string }) {
const [approveState, approveAction, approving] = useActionState(approveBooking, null);
const [rejectState, rejectAction, rejecting] = useActionState(rejectBooking, null);
return (
<div className="flex gap-2">
<form action={approveAction}>
<input type="hidden" name="bookingId" value={bookingId} />
<button type="submit" disabled={approving} className="rounded bg-green-700 px-3 py-1 text-white disabled:bg-slate-300">
{approving ? "處理中…" : "通過"}
</button>
</form>
<form action={rejectAction}>
<input type="hidden" name="bookingId" value={bookingId} />
<button type="submit" disabled={rejecting} className="rounded bg-red-700 px-3 py-1 text-white disabled:bg-slate-300">
{rejecting ? "處理中…" : "退件"}
</button>
</form>
</div>
);
}
每個動作獨立表單、獨立 useActionState、獨立 pending 狀態。Server Action 端則各自做權限檢查(Day 22 會展開):
// src/features/booking/actions.ts(接續)
"use server";
import { requireAdmin } from "@/lib/auth";
export async function approveBooking(_: unknown, formData: FormData) {
const session = await requireAdmin(); // 未登入或非管理員自動跳轉
const id = formData.get("bookingId")?.toString();
if (!id) return { ok: false, error: "缺少 bookingId" };
await updateBookingStatus(id, "approved", session.userId);
revalidatePath("/admin/bookings");
return { ok: true };
}
export async function rejectBooking(_: unknown, formData: FormData) {
const session = await requireAdmin();
const id = formData.get("bookingId")?.toString();
if (!id) return { ok: false, error: "缺少 bookingId" };
await updateBookingStatus(id, "rejected", session.userId);
revalidatePath("/admin/bookings");
return { ok: true };
}
這個範例展示「同一個業務有多個動作」的標準寫法:每個動作獨立函式、獨立 form、獨立狀態。Server Action 內的 requireAdmin() 是 Day 22 會寫的工具函式,未登入或非管理員自動跳轉到登入頁。整套結構讓「審核介面」的程式碼大幅縮短、權限檢查集中、不會漏寫。
表單的延伸:表單陣列與動態欄位
實務上常見的需求是「動態欄位數量」——例如「參加人數」可能從 1 到 10 個人,每人都有自己的姓名與 email。表單陣列用 name="participants[0].name" 的命名約定,Server Action 端用 zod 的 array() 驗證:
// src/features/booking/schemas.ts
// 表單陣列驗證
import { z } from "zod";
const ParticipantSchema = z.object({
name: z.string().min(1, "請輸入姓名"),
email: z.string().email("Email 格式錯誤"),
});
const NewGroupBookingSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
participants: z.array(ParticipantSchema).min(1).max(10),
});
// 在 action 內把 FormData 攤平成物件陣列
export function parseParticipants(formData: FormData) {
const result: { name: string; email: string }[] = [];
for (let i = 0; i < 10; i++) {
const name = formData.get(`participants[${i}].name`)?.toString();
if (!name) break;
const email = formData.get(`participants[${i}].email`)?.toString() ?? "";
result.push({ name, email });
}
return result;
}
前端要寫一支「動態新增欄位」的 Client Component,用 useState 追蹤目前的欄位數量、每按一次「新增參加者」就 push 一個空白欄位。這部分用 react-hook-form 之類的函式庫會更省事,但本系列刻意保持純 React 寫法。
另一個延伸是「表單狀態保留」。使用者填到一半、按了某個連結跳走、再按瀏覽器上一頁——表單內容還在嗎?傳統 SPA 要自己寫 useState 保留;Server Component 模式下瀏覽器會自動保留 form state(因為 form submit 是 POST,瀏覽器預設保留 POST 結果),但中間的過渡內容(半填的欄位)通常不會保留。實務上的修法:把 form 內容即時儲存到 sessionStorage 或 React Query 的 persisted state。
常見錯誤與踩雷
第一個雷是「Server Action 內讀取 cookies / headers,卻把它寫在 client 檔」。例如在 "use client" 的元件檔裡 import 一支 server function 直接呼叫,這違反「client 不能直接執行 server 程式」的規則。修法是把 server function 放進 "use server" 標記的檔或函式,再用 form action 或 router 呼叫它。
第二個雷是「忘記 revalidatePath 導致新增的資料沒出現」。Server Action 預設不會觸發任何 cache invalidation;如果你的清單頁是 Server Component 並用 fetch 抓資料,那新增預約後「/bookings 還是舊的」。修法是在 action 內呼叫 revalidatePath("/bookings"),或在 fetch 層用 cache: "no-store" 關閉快取。
第三個雷是「redirect 之後還寫 return」。redirect 內部會丟出 next/navigation 的特殊錯誤,這個錯誤會被框架攔截、變成實際的 HTTP redirect。所以 redirect 後面的程式碼不會執行;但 TypeScript 不知道這件事,可能會抱怨「函式結尾缺少 return」。解法是丟一個 return 兜底,或把 redirect 放在 try/catch 外面。
第四個雷是「useActionState 的 initial state 寫成 undefined」。這個 hook 必須有 initial state(即使是 null),且型別要跟 action 的回傳型別一致。寫成 undefined 會讓後續 state?.xxx 的型別推導失敗。建議寫法是定義一個 const initial = null as NewBookingResult | null;。
第五個雷是「form 內放 Server Component,卻在 form 裡用 useState」。form 的 action 不能跟 useState 混用——如果你需要 client-side 即時驗證(例如「密碼強度」),要在 client 子元件做,並用 useEffect 或 form 的 onChange 觸發。Server Action 只負責「最後送出」這一步。
效能與實務提醒
Server Actions 在 dev mode 比較慢,因為每個 form 都要編譯 RSC payload;build 模式會把所有 server function 預先編譯,效能明顯改善。實務上量測時一定要 pnpm build && pnpm start 才能看到真實數字,不要被 dev 的慢速度誤導。
Server Action 的另一個特性是「自動 batching」。同一個 form submit 內呼叫多個 server function(雖然不常見),框架會自動合併成一次 round-trip。對 booking 預約系統這種「單一動作觸發單一更新」的情境沒影響;對「一次提交內更新多個資源」的情境(例如「建立訂單 + 扣庫存 + 發通知」)可以節省網路成本。
CSRF 防護是內建的。Next.js 15 的 Server Action 用加密的 action ID(每個 server function 一個 unique ID)防止跨站請求偽造。瀏覽器送出 POST 時自動帶上加密的 action ID,伺服器驗證後才執行。所以你不用自己寫 CSRF token、或加 Origin header 檢查。
最後是「"use server" 標記的檔 vs 函式」。兩種寫法都可以:整支檔案標 "use server"(每個 export 都是 server action)、或單一函式標 "use server"(其他 export 可以是 client-safe 的工具)。檔案級標記比較常見,適合「一支檔專門放 server actions」的分層;函式級標記適合「helper 跟 action 混在一起的工具檔」。
小結
今天把「表單」從「傳統 onSubmit + fetch + useState」升級到「Server Action + useActionState」的現代寫法,重點有四個。第一,Server Action 是「"use server" 標記的非同步函式」,瀏覽器送表單時框架自動呼叫、無需寫 API 端點。第二,useActionState 把 action、狀態、pending 三件事整合在一個 hook,省下大量 useState。第三,驗證用 zod 集中定義、欄位錯誤回傳結構化結果,TypeScript 編譯器強制處理每個分支。第四,搭配 revalidatePath 自動更新清單頁、redirect 完成後跳轉,整個流程不需要手動管理 cache 或 router。
這套寫法對 booking 預約系統的價值是「新增預約流程從 80 行縮短到 20 行」,同時把驗證、錯誤處理、revalidate 全部標準化。對照後端,這就像從手寫 controller 加 form 加 service 加 revalidate 邏輯,簡化到只要一支函式 + 一個 schema。明天 Day 22 會進入「認證與 session 管理」,把 Server Action 跟 session 綁在一起,實作「只有登入使用者能新增預約」的權限檢查。
結語
明天,我們會正式進入 Day 22「認證與 session 管理」。我們會用 cookies() 讀寫 cookie、用 middleware 做 route 保護、用 Server Action 內的 session 檢查實作「未登入就跳轉 / 首頁」。booking 預約系統的「個人化預約清單」、「管理者後台」、「角色導向」都會在這一篇鋪好基礎。讀完你應該能區分 cookie-based session、JWT、httpOnly cookie 的差別,並能在 Next.js 15 內寫出符合業界標準的認證流程。
延伸資源
- Next.js 15 官方 Server Actions 章節:
https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations - React 19 useActionState 官方說明:
https://react.dev/reference/react/useActionState - React 19 useFormStatus 官方說明:
https://react.dev/reference/react-dom/hooks/useFormStatus - zod 官方說明:
https://zod.dev/,TypeScript-first 的 schema 驗證,搭配 Server Action 做表單驗證。 - Next.js 官方 revalidatePath 章節:
https://nextjs.org/docs/app/api-reference/functions/revalidatePath
留言
張貼留言