跳到主要內容

FE Day 21 表單與 Server Actions

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

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...