Chapter 05

Server Actions 与表单

从表单处理到乐观更新,用 Server Actions 彻底简化全栈数据变更

5.1 Server Actions 是什么

Server Actions 是 Next.js(基于 React 19 规范)提供的一种机制,允许在服务端执行异步函数,可以直接从 Client Component 调用,无需手动创建 API 路由。Next.js 会自动为每个 Server Action 生成一个唯一的 POST 端点,客户端调用时通过这个端点通信。

Server Actions 彻底改变了全栈开发的工作流:以往需要写 API Route → 前端 fetch → 处理错误的三步流程,现在只需在服务端函数上标注 'use server',前端像调用普通函数一样调用即可。安全性由框架保证,CSRF 防护内置其中。

ℹ️

内置安全性Server Actions 内置了 CSRF 防护——Next.js 会验证请求的 Origin 头与当前域名匹配,且只接受 POST 请求。此外,Action 的 ID 是随机生成的哈希值,不会暴露函数名或实现细节。

5.2 定义与调用 Server Actions

有两种方式定义 Server Actions:

// 方式1:在专用文件中定义(推荐,复用性好)
// app/actions/post.ts
'use server'

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const title   = formData.get('title')   as string
  const content = formData.get('content') as string

  // 数据验证
  if (!title || title.length < 3) {
    return { error: '标题至少3个字符' }
  }

  // 直接操作数据库
  await db.post.create({
    data: { title, content }
  })

  // 使相关路径的缓存失效
  revalidatePath('/blog')

  // 重定向到列表页
  redirect('/blog')
}
TS
// 方式2:在 Server Component 内联定义
// app/new-post/page.tsx
export default function NewPostPage() {
  // 内联定义,需要标注 'use server'
  async function createPost(formData: FormData) {
    'use server'
    const title = formData.get('title')
    await db.post.create({ data: { title } })
  }

  return (
    {/* form 的 action 直接传入函数 */}
    <form action={createPost}>
      <input name="title" />
      <button type="submit">创建</button>
    </form>
  )
}
TSX

5.3 表单与 useFormStatus

React 19 引入了 useFormStatus Hook,可以在表单的子组件中获取当前表单的提交状态。这解决了以往需要手动管理 loading 状态的问题——当表单正在提交 Server Action 时,pending 自动变为 true

注意:useFormStatus 必须在 <form>子组件内使用,不能在 form 本身的组件中使用。

'use client'

import { useFormStatus } from 'react-dom'

// 提交按钮组件(必须是 form 的子组件)
function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      aria-disabled={pending}
    >
      {pending ? '提交中...' : '提交'}
    </button>
  )
}

// 表单组件(可以是 Server Component)
import { createPost } from '@/app/actions/post'

export default function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="标题" required />
      <textarea name="content" placeholder="内容" />
      <SubmitButton /> {/* 子组件,可以读取 form 状态 */}
    </form>
  )
}
TSX

5.4 useActionState — 处理错误与反馈

useActionState(React 19 正式 API,Next.js 14 中叫 useFormState)允许 Server Action 返回状态,并在客户端组件中读取该状态。这是处理表单验证错误、成功消息的标准模式。

// app/actions/post.ts
'use server'

export type ActionState = {
  error?: string
  success?: boolean
}

export async function createPost(
  prevState: ActionState,  // useActionState 注入
  formData: FormData
): Promise<ActionState> {
  const title = formData.get('title') as string

  if (!title) {
    return { error: '标题不能为空' }
  }

  try {
    await db.post.create({ data: { title } })
    revalidatePath('/blog')
    return { success: true }
  } catch (e) {
    return { error: '创建失败,请稍后重试' }
  }
}
TS
'use client'

import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { createPost, type ActionState } from '@/app/actions/post'

export function CreatePostForm() {
  const [state, action, pending] = useActionState<ActionState, FormData>(
    createPost,
    {} // 初始状态
  )

  return (
    <form action={action}>
      {state.error && (
        <p className="error">{state.error}</p>
      )}
      {state.success && (
        <p className="success">创建成功!</p>
      )}
      <input name="title" disabled={pending} />
      <button type="submit" disabled={pending}>
        {pending ? '提交中...' : '创建文章'}
      </button>
    </form>
  )
}
TSX

5.5 useOptimistic — 乐观更新

乐观更新(Optimistic Update)是一种 UI 模式:在 Server Action 完成之前,先在 UI 上预先显示预期的结果。如果 Action 成功,UI 保持不变;如果失败,UI 回滚到原始状态。这极大提升了用户感知的响应速度,是现代应用(如 Twitter 点赞、评论)的标配体验。

React 19 的 useOptimistic Hook 内置了这个模式的状态管理。

'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions/like'

interface LikeButtonProps {
  postId: string
  initialLiked: boolean
  likeCount: number
}

export function LikeButton({ postId, initialLiked, likeCount }: LikeButtonProps) {
  const [isPending, startTransition] = useTransition()

  // useOptimistic:第一个参数是真实状态,第二个是更新函数
  const [optimisticState, setOptimistic] = useOptimistic(
    { liked: initialLiked, count: likeCount },
    (current, newLiked: boolean) => ({
      liked: newLiked,
      count: newLiked ? current.count + 1 : current.count - 1
    })
  )

  async function handleClick() {
    startTransition(async () => {
      // 立即更新 UI(乐观)
      setOptimistic(!optimisticState.liked)
      // 实际调用服务端(可能有延迟)
      await toggleLike(postId)
      // 成功后 optimisticState 被真实状态替换
      // 失败后 React 会自动回滚乐观状态
    })
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {optimisticState.liked ? '❤️' : '🤍'} {optimisticState.count}
    </button>
  )
}
TSX

5.6 Zod 表单数据验证

生产应用中应使用 Zod 进行严格的数据验证。Zod 提供类型安全的 Schema 定义,既可以在 Server Action 中验证输入数据,也可以共用于前端实时验证。

import { z } from 'zod'

// 定义验证 Schema
const CreatePostSchema = z.object({
  title: z.string()
    .min(3, '标题至少3个字符')
    .max(100, '标题最多100个字符'),
  content: z.string().min(10, '内容至少10个字符'),
  published: z.boolean().default(false),
})

export type CreatePostInput = z.infer<typeof CreatePostSchema>

export async function createPost(prevState: ActionState, formData: FormData) {
  'use server'

  const raw = {
    title:     formData.get('title'),
    content:   formData.get('content'),
    published: formData.get('published') === 'true',
  }

  const result = CreatePostSchema.safeParse(raw)

  if (!result.success) {
    // 返回字段级别的错误信息
    return {
      error: result.error.flatten().fieldErrors,
    }
  }

  await db.post.create({ data: result.data })
  revalidatePath('/blog')
  return { success: true }
}
TS
💡

永远不要信任客户端输入即使在前端做了验证,Server Action 中必须重新验证所有输入数据。恶意用户可以绕过前端直接向 Action 端点发送请求。Zod 的 safeParse 不会抛出异常,适合服务端使用。