使用 Next.js Server Actions 创建表单

React Server Actions 是在服务器上执行的 Server Functions,可以从服务器组件和客户端组件调用,用于处理表单提交。本文介绍在 Next.js 中使用它们创建表单。关于单次往返响应、顺序分发、安全和部署等表单以外的行为,见 Server Actions 与数据修改。

警告:每个 Server Action 内都必须验证身份与授权,即使表单只显示在需要登录的页面中。详情见 数据安全指南。

工作原理

React 扩展了 HTML 的 form 元素,允许通过 action 属性调用 Server Action。用于表单时,函数自动接收 FormData,可用原生方法提取数据。

TypeScript 示例:

import { auth } from '@/lib/auth'

export default function Page() {
  async function createInvoice(formData: FormData) {
    'use server'

    const session = await auth()
    if (!session?.user) {
      throw new Error('Unauthorized')
    }

    const rawFormData = {
      customerId: formData.get('customerId'),
      amount: formData.get('amount'),
      status: formData.get('status'),
    }

    // mutate data
    // revalidate the cache
  }

  return <form action={createInvoice}>...</form>
}

JavaScript 示例:

import { auth } from '@/lib/auth'

export default function Page() {
  async function createInvoice(formData) {
    'use server'

    const session = await auth()
    if (!session?.user) {
      throw new Error('Unauthorized')
    }

    const rawFormData = {
      customerId: formData.get('customerId'),
      amount: formData.get('amount'),
      status: formData.get('status'),
    }

    // mutate data
    // revalidate the cache
  }

  return <form action={createInvoice}>...</form>
}

提示:多个字段可以用 Object.fromEntries(formData) 转成对象,但结果还会包含以 $ACTION_ 开头的额外属性。

传递额外参数

除表单字段外,可以通过 JavaScript 的 bind 传递额外参数。例如,把 userId 传给 updateUser。

TypeScript:

'use client'

import { updateUser } from './actions'

export function UserProfile({ userId }: { userId: string }) {
  const updateUserWithId = updateUser.bind(null, userId)

  return (
    <form action={updateUserWithId}>
      <input type="text" name="name" />
      <button type="submit">Update User Name</button>
    </form>
  )
}

JavaScript:

'use client'

import { updateUser } from './actions'

export function UserProfile({ userId }) {
  const updateUserWithId = updateUser.bind(null, userId)

  return (
    <form action={updateUserWithId}>
      <input type="text" name="name" />
      <button type="submit">Update User Name</button>
    </form>
  )
}

服务器函数会将 userId 作为额外参数接收。TypeScript:

'use server'

export async function updateUser(userId: string, formData: FormData) {}

JavaScript:

'use server'

export async function updateUser(userId, formData) {}

提示:也可使用隐藏字段,例如 <input type="hidden" name="userId" value={userId} />,但值会出现在渲染后的 HTML 中,不会被编码。bind 同时适用于服务器和客户端组件,也支持渐进增强。

表单验证

可以在客户端或服务器验证。客户端基础验证可用 required、type=”email” 等 HTML 属性。服务器端可用 Zod 或 Valibot 等 schema 库。

TypeScript:

'use server'

import { z } from 'zod'

const schema = z.object({
  email: z.string({
    invalid_type_error: 'Invalid Email',
  }),
})

export default async function createUser(formData: FormData) {
  const validatedFields = schema.safeParse({
    email: formData.get('email'),
  })

  // Return early if the form data is invalid
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // Mutate data
}

JavaScript:

'use server'

import { z } from 'zod'

const schema = z.object({
  email: z.string({
    invalid_type_error: 'Invalid Email',
  }),
})

export default async function createUser(formData) {
  const validatedFields = schema.safeParse({
    email: formData.get('email'),
  })

  // Return early if the form data is invalid
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // Mutate data
}

验证错误

要显示错误或消息,将定义 form 的组件改为客户端组件,使用 React 的 useActionState。

使用该 hook 后,服务器函数签名会变化,第一个参数变为新的 prevState 或 initialState。

TypeScript:

'use server'

import { z } from 'zod'

export async function createUser(initialState: any, formData: FormData) {
  const validatedFields = schema.safeParse({
    email: formData.get('email'),
  })
  // ...
}

JavaScript:

'use server'

import { z } from 'zod'

// ...

export async function createUser(initialState, formData) {
  const validatedFields = schema.safeParse({
    email: formData.get('email'),
  })
  // ...
}

然后根据 state 对象有条件地显示错误消息。

TypeScript:

'use client'

import { useActionState } from 'react'
import { createUser } from '@/app/actions'

const initialState = {
  message: '',
}

export function Signup() {
  const [state, formAction, pending] = useActionState(createUser, initialState)

  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p aria-live="polite">{state?.message}</p>
      <button disabled={pending}>Sign up</button>
    </form>
  )
}

JavaScript:

'use client'

import { useActionState } from 'react'
import { createUser } from '@/app/actions'

const initialState = {
  message: '',
}

export function Signup() {
  const [state, formAction, pending] = useActionState(createUser, initialState)

  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input type="text" id="email" name="email" required />
      {/* ... */}
      <p aria-live="polite">{state?.message}</p>
      <button disabled={pending}>Sign up</button>
    </form>
  )
}

等待状态

useActionState 暴露布尔值 pending,在操作执行期间可显示加载状态或禁用提交按钮。

TypeScript:

'use client'

import { useActionState } from 'react'
import { createUser } from '@/app/actions'

export function Signup() {
  const [state, formAction, pending] = useActionState(createUser, initialState)

  return (
    <form action={formAction}>
      {/* Other form elements */}
      <button disabled={pending}>Sign up</button>
    </form>
  )
}

JavaScript:

'use client'

import { useActionState } from 'react'
import { createUser } from '@/app/actions'

export function Signup() {
  const [state, formAction, pending] = useActionState(createUser, initialState)

  return (
    <form action={formAction}>
      {/* Other form elements */}
      <button disabled={pending}>Sign up</button>
    </form>
  )
}

也可以使用 useFormStatus,但需要单独创建显示等待状态的组件。例如,在等待期间禁用按钮。

TypeScript:

'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button disabled={pending} type="submit">
      Sign Up
    </button>
  )
}

JavaScript:

'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button disabled={pending} type="submit">
      Sign Up
    </button>
  )
}

随后将 SubmitButton 嵌套在表单中。

TypeScript:

import { SubmitButton } from './button'
import { createUser } from '@/app/actions'

export function Signup() {
  return (
    <form action={createUser}>
      {/* Other form elements */}
      <SubmitButton />
    </form>
  )
}

JavaScript:

import { SubmitButton } from './button'
import { createUser } from '@/app/actions'

export function Signup() {
  return (
    <form action={createUser}>
      {/* Other form elements */}
      <SubmitButton />
    </form>
  )
}

提示:React 19 的 useFormStatus 返回对象还包含 data、method、action 等键。早期版本只提供 pending。

提示:启用实验性 useOffline 配置后,Server Action 如果因断网中断,会保持等待并在网络恢复后完成,避免用户丢失提交。

乐观更新

通过 useOptimistic,可以在服务器函数完成之前先乐观更新界面,无需等待响应。

TypeScript:

'use client'

import { useOptimistic } from 'react'
import { send } from './actions'

type Message = {
  message: string
}

export function Thread({ messages }: { messages: Message[] }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic<
    Message[],
    string
  >(messages, (state, newMessage) => [...state, { message: newMessage }])

  const formAction = async (formData: FormData) => {
    const message = formData.get('message') as string
    addOptimisticMessage(message)
    await send(message)
  }

  return (
    <div>
      {optimisticMessages.map((m, i) => (
        <div key={i}>{m.message}</div>
      ))}
      <form action={formAction}>
        <input type="text" name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

JavaScript:

'use client'

import { useOptimistic } from 'react'
import { send } from './actions'

export function Thread({ messages }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage) => [...state, { message: newMessage }]
  )

  const formAction = async (formData) => {
    const message = formData.get('message')
    addOptimisticMessage(message)
    await send(message)
  }

  return (
    <div>
      {optimisticMessages.map((m) => (
        <div>{m.message}</div>
      ))}
      <form action={formAction}>
        <input type="text" name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

嵌套表单元素

form 内部的 button、input type=”submit” 和 input type=”image” 等元素,也可以调用 Server Actions。这些元素支持 formAction 属性或事件处理器。

需要一个表单执行多个操作时很有用。例如,除发布文章外,再添加专门保存草稿的按钮。更多信息见 React form 文档中的多种提交类型说明。

编程式提交

通过 requestSubmit() 可以主动提交表单。例如,监听 onKeyDown,让用户按 ⌘ + Enter 提交。

TypeScript:

'use client'

export function Entry() {
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (
      (e.ctrlKey || e.metaKey) &&
      (e.key === 'Enter' || e.key === 'NumpadEnter')
    ) {
      e.preventDefault()
      e.currentTarget.form?.requestSubmit()
    }
  }

  return (
    <div>
      <textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
    </div>
  )
}

JavaScript:

'use client'

export function Entry() {
  const handleKeyDown = (e) => {
    if (
      (e.ctrlKey || e.metaKey) &&
      (e.key === 'Enter' || e.key === 'NumpadEnter')
    ) {
      e.preventDefault()
      e.currentTarget.form?.requestSubmit()
    }
  }

  return (
    <div>
      <textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
    </div>
  )
}

它会提交最近的 form 祖先元素,进而调用服务器函数。


原文:How to create forms with Server Actions。作者/维护者:Vercel 与 Next.js 文档贡献者。本文为原文的中文译文;代码保留原文内容。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容