Next.jsReact

Next.js 的 Server Actions:JavaScript 尚未加載,表單已經在運作

Next.js 的 Server Actions:JavaScript 尚未加載,表單已經在運作

React 表單的經典寫法:onSubmitevent.preventDefault()fetch('/api/todos', { method: 'POST', body: JSON.stringify(...) }),再加上 useState 處理載入狀態。這樣寫行得通——但有一個很容易被忽略的前提,因為在你自己那台連線快速的手提電腦上,這個前提永遠不會露出破綻:JavaScript 必須先加載完畢、解析並執行完成,表單才會開始對點擊作出反應。在網絡較慢的手機連線上、在大型頁面正在進行 hydration 的過程中,或者當某個瀏覽器擴充功能剛好把 script 擋住時——用戶按下「送出」,畫面上甚麼都不會發生。沒有錯誤訊息,沒有 spinner——因為 onClick 這時根本還不存在。

Next.js 的 Server Actions 有時會被介紹成同一種呼叫方式的更方便寫法——「其實就是 fetch,只是不用自己寫 API route」。這種簡化的講法,恰恰漏掉了這個機制真正新穎的地方:<form action={myAction}> 依靠的是瀏覽器自古以來就有的原生表單提交機制,而不是把 JavaScript 當成必要條件。這不但改變了它底層的運作方式,也改變了那些把 "use server" 當成普通本地函式呼叫來用的團隊,實際上會踩到的錯誤。

"use server" 指令實際上做了甚麼

當你把一個函式標記為 "use server" 時,Next.js 的編譯器不會把它的函式主體留在送到瀏覽器的那份 bundle 裡。它會把主體整段抽走,換成一個參照——一個加密過的識別碼,指向伺服器上該呼叫哪個函式。真正送到 client 的,只是一個很小的 stub:「當你呼叫我的時候,向那個專門的 RSC endpoint 發一個 POST,帶上這個識別碼和序列化後的參數」。函式本身的邏輯——查詢資料庫、驗證、不管它做甚麼——實際上從未離開過伺服器。

tsx

// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function addTodoAction(prevState: unknown, formData: FormData) {
const title = formData.get("title");
if (typeof title !== "string" || title.trim().length === 0) {
return { error: "標題不可以是空白。" };
}
await db.todo.create({ data: { title: title.trim() } });
revalidatePath("/todos");
return { error: null };
}

當你把這個函式綁到 <form action={addTodoAction}> 上時,會發生一件普通 fetch 永遠無法免費給你的事:它在任何 JavaScript 執行之前就已經在運作。只要 action 屬性指向一個 URL,瀏覽器本來就一直懂得把表單當成原生 POST 送出——React 19 和 Next.js 把這個機制擴展到讓 action 也可以是一個伺服器函式,只要 JS 還沒準備好,瀏覽器照樣會走它原生的那條路。等 JavaScript 真正執行起來後,Next.js 會攔截這個事件,在背景用 fetch 做同一件事,不用重新載入頁面,UI 也能順暢更新。這是內建在框架裡的 progressive enhancement,不是你要自己動手實作的東西——跟我們在無障礙設計那篇文章談到 RouteAnnouncer 時提到的機制完全一樣,只是這次用在資料的變更上,而不是導覽上。

實際範例:不用手寫 fetch,也有完整狀態處理的表單

React 19 特別為這種模式新增了 useActionState——它把伺服器 action 的呼叫,跟這個 action 上一次執行後回傳的狀態連結在一起,不需要自己用 useState 來存錯誤訊息或載入狀態。

tsx

"use client";
import { useActionState } from "react";
import { addTodoAction } from "./actions";
const initialState = { error: null };
const TodoForm = () => {
const [state, formAction, isPending] = useActionState(
addTodoAction,
initialState,
);
return (
<form action={formAction}>
<input type="text" name="title" placeholder="新任務" required />
<button type="submit" disabled={isPending}>
{isPending ? "新增中…" : "新增"}
</button>
{state.error && <p className="form-error">{state.error}</p>}
</form>
);
};
export default TodoForm;

這裡同時發生了好幾件事,卻沒有一行程式碼是專門用來手動管理 request 的。isPending 反映的是表單提交的真實狀態——包括那個在 hydration 之前就已經送出的原生 POST,而不只是 client 端發起的呼叫。state.error 就是伺服器函式直接回傳的值——不用解析 JSON 回應,也不用在 fetch 外面包一層 try/catch。action 裡的 revalidatePath("/todos") 會叫 Next.js 重新驗證這條路由的快取,所以頁面上的任務清單會顯示新的項目,而不需要手動刷新 client 端的狀態——是框架、而不是你,決定何時、以及如何拉取新的 RSC payload。

常見陷阱與最佳實踐

  • 每一個 "use server" 函式都是一個公開的 HTTP 端點——不是私有函式。 這是 Server Actions 最常見的安全誤解:既然 UI 裡沒有任何東西直接連到它,看起來就好像是「隱藏」的。其實不是——編譯器會為它產生一個固定、可以從外部呼叫的 action 識別碼,所以任何知道這個識別碼的人(例如在 DevTools 裡看一下網絡流量),都可以繞過你的 UI 和它背後的假設,直接呼叫這個函式。輸入驗證和權限檢查(session、用戶角色)必須寫在 action 內部,就跟寫一個傳統的 API endpoint 一樣——永遠不要依賴「未登入的用戶看不到這個按鈕」這種假設。
  • 參數和回傳值都必須可以序列化。 Server Actions 透過網絡傳輸資料時,用的是 React 自己的序列化格式(比 JSON 更豐富——支援 DateMap,還有直接從表單傳過來的 FormData 等),但依然不允許傳函式、class 實例,或者 DOM 物件的參照。如果你用 .bind() 在 client 端呼叫時預先綁定一個額外參數(addTodoAction.bind(null, listId)),記住 listId 同樣必須是可序列化的。
  • 它終究是一個真實的網絡 request,不是本地函式呼叫。 這一點很容易被忘記,因為 await myAction(data) 這種寫法,語法上看起來跟呼叫普通 JS 函式一模一樣。但底層每一次都是完整的一趟來回:序列化、HTTP、再在另一端反序列化。如果你在文字輸入框每打一個字就在迴圈裡呼叫一次 Server Action(例如做即時驗證),你的表單就會變成一部 request 產生器,而不是一個反應靈敏的輸入框——這種情況下 debounce 一樣是必要的,跟其他任何網絡呼叫沒有分別。
  • 沒有呼叫 revalidatePathrevalidateTag,UI 就會停留在過期的快取上。 Next.js 預設會快取頁面資料——如果一個 action 把資料寫進資料庫,卻沒有告訴框架該重新整理哪條路由,就會經常出現「明明存進去了,但畫面上看不到」的回報:資料庫裡的資料是對的,只是 UI 顯示的仍然是舊的、被快取住的頁面版本。

總結

Server Actions 不是「語法比較漂亮的 fetch」——它們是建立在同一套機制上的兩件不同的事。第一,它們依靠瀏覽器原生的表單提交機制,所以表單甚至在 JavaScript 加載完成之前就能運作——你不需要寫任何一行程式碼去實現這個行為,這是 <form action={...}> 這個架構本身就送給你的。第二,而且從安全角度來看更重要的是:每一個標記為 "use server" 的函式,都會變成一個真實存在、可以從外部呼叫的網絡端點,不管有沒有任何看得見的 UI 元素去呼叫它。把它當成一個本地的、可信任的函式來對待——內部沒有驗證也沒有授權——是讓這個方便的語法,變成一個不安全的 API endpoint 最簡單的方法。