✨ 引言:更智能的表单
在上一篇中,我们的 Server Action 成功执行并重定向,但用户在等待数据处理时,没有任何反馈。本篇我们将解决这个问题,并为表单添加基础验证,确保数据的有效性。
本篇目标(教程八):
- 使用
useFormStatusHook 封装提交按钮,实现加载状态。 - 使用
useFormStateHook 管理表单提交结果和错误信息。 - 在 Server Action 中添加基础的服务端验证。
一、实现加载状态 (useFormStatus)
我们从最直观的改进开始:当用户点击提交按钮时,按钮应该显示加载中。
1. 创建提交按钮组件
useFormStatus 必须在一个客户端组件中使用,并且该组件必须是其关联 <form> 的子组件。
bash
touch components/SubmitButton.tsx
components/SubmitButton.tsx 内容:
tsx
// components/SubmitButton.tsx
"use client";
import React from 'react';
import { useFormStatus } from 'react-dom'; // Next.js 自动封装了 React Hook
export default function SubmitButton() {
const { pending } = useFormStatus(); // 获取表单的提交状态
return (
<button
type="submit"
aria-disabled={pending} // 提交中时禁用按钮
disabled={pending}
className={`
px-6 py-3 font-semibold rounded-lg shadow-md transition duration-150 flex items-center justify-center
${pending
? 'bg-gray-400 cursor-not-allowed' // 加载中样式
: 'bg-indigo-600 hover:bg-indigo-700' // 默认样式
}
text-white
`}
>
{pending ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
正在发布...
</>
) : (
'发布文章'
)}
</button>
);
}
二、实现服务端验证 (useFormState)
我们需要修改 Server Action 来返回错误信息,并在客户端使用 useFormState 来接收并显示这些信息。
1. 修改 Server Action 结构
为了返回错误信息,我们修改 lib/actions.ts 中的 createPost 函数。
lib/actions.ts (部分修改):
typescript
// lib/actions.ts
"use server";
// ... (导入不变)
// 1. 定义表单状态类型,包含错误信息
export type FormState = {
errors?: {
title?: string[];
content?: string[];
global?: string;
};
};
// 2. 修改 createPost 函数签名,使其接受 prevState 并返回 FormState | void
const initialState: FormState = {};
export async function createPost(
prevState: FormState, // useFormState 传递的上一个状态
formData: FormData
): Promise<FormState> { // 返回新的状态或重定向 (void)
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// ... (其他字段获取不变)
const errors: FormState['errors'] = {};
// --- 验证逻辑 ---
if (!title || title.trim().length === 0) {
errors.title = ['文章标题不能为空。'];
} else if (title.length > 100) {
errors.title = ['标题长度不能超过 100 个字符。'];
}
if (!content || content.trim().length === 0) {
errors.content = ['文章内容不能为空。'];
}
// --- 检查是否有错误 ---
if (Object.keys(errors).length > 0) {
// 如果有错误,返回包含错误信息的 state
return { errors };
}
// --- 无错误时继续数据处理 ---
// 模拟数据写入/保存到数据库 (模拟 2 秒延迟)
console.log(`正在保存文章:${title}`);
await new Promise(resolve => setTimeout(resolve, 2000));
// 实际的数据库插入操作...
// 成功后重定向
revalidatePath('/posts');
redirect('/posts');
}
注意: 当
redirect()被调用时,Server Action 不会返回FormState,而是直接中断并跳转。
2. 在表单页面使用 useFormState
修改 app/posts/create/page.tsx,将其转换为客户端组件(因为 useFormState 是 Hook)并显示错误信息。
app/posts/create/page.tsx 内容:
tsx
// app/posts/create/page.tsx
"use client"; // 声明为客户端组件
import React from 'react';
import { useFormState } from 'react-dom';
import { createPost, FormState } from '@/lib/actions';
import SubmitButton from '@/components/SubmitButton';
// 定义初始状态
const initialState: FormState = {};
export default function CreatePostPage() {
// 1. 使用 useFormState 绑定 Server Action 并管理状态
const [state, formAction] = useFormState(createPost, initialState);
return (
<div>
<h1 className="text-3xl font-bold mb-6 text-gray-800">📝 发布新文章</h1>
{/* 2. 将 formAction 绑定到表单的 action 属性 */}
<form action={formAction} className="bg-white p-8 rounded-lg shadow-md space-y-6">
{/* 标题输入框 */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
文章标题
</label>
<input
id="title"
name="title"
type="text"
required
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
placeholder="请输入文章标题"
/>
{/* 3. 显示标题验证错误 */}
{state.errors?.title && state.errors.title.map((error: string) => (
<p key={error} className="text-sm text-red-500 mt-1">{error}</p>
))}
</div>
{/* 分类选择 (保持不变) */}
<div>
{/* ... */}
</div>
{/* 内容输入框 */}
<div>
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-1">
文章内容
</label>
<textarea
id="content"
name="content"
rows={10}
required
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
placeholder="在这里撰写您的文章内容..."
/>
{/* 4. 显示内容验证错误 */}
{state.errors?.content && state.errors.content.map((error: string) => (
<p key={error} className="text-sm text-red-500 mt-1">{error}</p>
))}
</div>
{/* 提交按钮:使用封装好的 SubmitButton */}
<div className="flex justify-end">
<SubmitButton />
</div>
{/* 5. 显示全局错误 (如果需要) */}
{state.errors?.global && (
<p className="text-center text-sm text-red-600 border border-red-200 p-2 rounded-lg bg-red-50">{state.errors.global}</p>
)}
</form>
</div>
);
}
3. 运行与验证
- 运行
npm run dev。 - 导航到
/posts/create。 - 验证加载状态: 正常填写表单,点击提交。按钮会显示"正在发布..."和加载图标,且按钮被禁用,直到重定向完成。
- 验证错误状态: 尝试留空标题或内容字段,然后点击提交。按钮会短暂显示加载状态,然后 Server Action 返回错误,页面停留在当前表单,并在对应的字段下方显示红色错误信息。
💡 总结与预告
在本篇教程中,我们完成了:
- ✅ 使用
useFormStatus实现了表单提交时的加载状态和按钮禁用。 - ✅ 使用
useFormState管理表单的提交状态和 Server Action 返回的错误信息。 - ✅ 在服务端 Server Action 中添加了基础的字段验证逻辑。
至此,我们的文章管理模块已经具备了完整的 CRUD 功能和良好的用户体验。在下一篇教程中,我们将为后台系统添加另一个基础模块:用户管理 ,并介绍路由组的概念,以便更好地组织后台系统的路由结构。