Server Actions 不只是替代 API 路由的語法糖,它改變了資料流的組織方式。在實際專案中,我們需要一套成熟的設計模式來管理校驗、錯誤處理、許可權控制和狀態同步。這篇文章總結了我在生產環境中驗證過的幾種模式。
模式一:Command 模式封裝業務邏輯
將 Server Action 按業務領域組織,每個 action 只做一件事。這比把所有 action 塞進一個 actions.ts 檔案更可維護。
tsx
// actions/post.ts
'use server'
import { z } from 'zod'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { requireAuth } from '@/lib/auth'
import { db } from '@/lib/db'
const createPostSchema = z.object({
title: z.string().min(2, '標題至少2個字').max(100),
content: z.string().min(10, '內容至少10個字'),
categoryId: z.string().uuid(),
})
export type CreatePostResult =
| { success: true; postId: string }
| { success: false; errors: Record<string, string[]> }
export async function createPost(_: any, formData: FormData): Promise<CreatePostResult> {
const user = await requireAuth()
const raw = {
title: formData.get('title'),
content: formData.get('content'),
categoryId: formData.get('categoryId'),
}
const parsed = createPostSchema.safeParse(raw)
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors }
}
const post = await db.post.create({
data: { ...parsed.data, authorId: user.id },
})
revalidateTag('posts')
redirect(`/blog/${post.slug}`)
}
requireAuth() 是獨立的鑑權函式,每個需要鑑權的 action 都呼叫它。不要用 middleware 做鑑權判斷,middleware 無法訪問資料庫 session。
模式二:Hook 化的 Action 呼叫
封裝一個自定義 hook,統一處理 loading、error、toast 反饋,避免在每個元件中重複 useFormState + useTransition 的樣板程式碼。
tsx
// hooks/use-action.ts
'use client'
import { useFormState } from 'react-dom'
import { useEffect } from 'react'
import { toast } from 'sonner'
interface Options<T> {
onSuccess?: (data: T) => void
onError?: (errors: Record<string, string[]>) => void
successMessage?: string
}
export function useAction<T>(
action: (state: any, formData: FormData) => Promise<T>,
options: Options<T> = {}
) {
const [state, formAction] = useFormState(action, null)
useEffect(() => {
if (!state) return
if (state.success) {
if (options.successMessage) toast.success(options.successMessage)
options.onSuccess?.(state)
} else if (state.errors) {
if (options.onError) {
options.onError(state.errors)
} else {
const firstError = Object.values(state.errors).flat()[0]
toast.error(firstError)
}
}
}, [state])
return { state, formAction, pending: state === null }
}
tsx
// 使用示例
'use client'
import { createComment } from '@/actions/comment'
import { useAction } from '@/hooks/use-action'
export function CommentForm({ postId }: { postId: string }) {
const { formAction, state } = useAction(createComment, {
successMessage: '評論已釋出',
onSuccess: () => {
// 可選:關閉彈窗、滾動到底部等
},
})
return (
<form action={formAction}>
<input type="hidden" name="postId" value={postId} />
<textarea name="content" placeholder="寫下你的評論..." />
{state?.errors?.content && (
<p className="text-sm text-red-500">{state.errors.content[0]}</p>
)}
<button type="submit">提交評論</button>
</form>
)
}
模式三:事務性批次操作
有時一個使用者操作需要更新多張表。Server Action 天然支援資料庫事務,不需要前端發多次請求。
tsx
// actions/admin.ts
'use server'
import { db } from '@/lib/db'
import { requireAdmin } from '@/lib/auth'
import { revalidateTag } from 'next/cache'
export async function transferPosts(fromUserId: string, toUserId: string) {
await requireAdmin()
const result = await db.$transaction(async (tx) => {
// 1. 轉移文章所有權
const posts = await tx.post.updateMany({
where: { authorId: fromUserId },
data: { authorId: toUserId },
})
// 2. 更新目標使用者的統計
await tx.userStats.update({
where: { userId: toUserId },
data: { postCount: { increment: posts.count } },
})
// 3. 更新源使用者的統計
await tx.userStats.update({
where: { userId: fromUserId },
data: { postCount: 0 },
})
// 4. 記錄操作日誌
await tx.auditLog.create({
data: {
action: 'TRANSFER_POSTS',
operatorId: fromUserId,
targetId: toUserId,
metadata: { count: posts.count },
},
})
return posts.count
})
revalidateTag('posts')
revalidateTag('users')
return { success: true, transferred: result }
}
模式四:樂觀更新 + 回滾
對於低風險操作(點贊、收藏、切換狀態),樂觀更新是最佳使用者體驗方案。關鍵在於做好失敗回滾。
tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleBookmark } from '@/actions/bookmark'
interface BookmarkState {
bookmarked: boolean
count: number
}
export function BookmarkToggle({ postId, initial }: {
postId: string
initial: BookmarkState
}) {
const [optimistic, setOptimistic] = useOptimistic(
initial,
(state, action: BookmarkState) => action
)
const [isPending, startTransition] = useTransition()
async function handleToggle() {
startTransition(async () => {
setOptimistic({
bookmarked: !optimistic.bookmarked,
count: optimistic.bookmarked ? optimistic.count - 1 : optimistic.count + 1,
})
const result = await toggleBookmark(postId)
// 如果失敗,useOptimistic 會自動回滾到 initial 狀態
// 框架通過 Server Action 返回的最終狀態做 reconcile
})
}
return (
<button onClick={handleToggle} disabled={isPending}>
{optimistic.bookmarked ? '★' : '☆'} {optimistic.count}
</button>
)
}
小結
- 用 zod 統一校驗 Server Action 輸入,永遠不要信任客戶端資料
- 封裝
useActionhook 減少樣板程式碼,統一 loading/error 處理 - Server Action 內可以直接用資料庫事務處理批次操作,前端不需要發多次請求
- 樂觀更新適合低風險操作,
useOptimistic框架級支援比手動實現更可靠 - 每個 action 都要鑑權,不要依賴 middleware 做許可權控制