Appearance
React 19 Form Actions表单
概述
React 19引入了全新的Form Actions特性,这是对表单处理的重大改进。通过Actions,可以更直接地处理表单提交、服务器交互和状态管理,无需手动管理loading状态和错误处理。本文将深入探讨React 19的Form Actions特性及其实际应用。
Form Actions基础
传统表单 vs Actions表单
jsx
// 传统方式
function TraditionalForm() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const formData = new FormData(e.target);
const response = await fetch('/api/submit', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('提交失败');
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="username" required />
<button type="submit" disabled={loading}>
{loading ? '提交中...' : '提交'}
</button>
{error && <div className="error">{error}</div>}
{data && <div>成功: {JSON.stringify(data)}</div>}
</form>
);
}
// React 19 Actions方式
function ActionsForm() {
async function submitForm(formData) {
'use server'; // Server Action标记
const username = formData.get('username');
// 直接在action中处理
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ username }),
});
if (!response.ok) {
throw new Error('提交失败');
}
return await response.json();
}
return (
<form action={submitForm}>
<input name="username" required />
<button type="submit">提交</button>
</form>
);
}useFormStatus Hook
jsx
import { useFormStatus } from 'react-dom';
// 提交按钮组件
function SubmitButton({ children }) {
const { pending, data, method, action } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '提交中...' : children}
</button>
);
}
// 使用示例
function FormWithStatus() {
async function handleSubmit(formData) {
await new Promise(resolve => setTimeout(resolve, 2000));
console.log('提交:', Object.fromEntries(formData));
}
return (
<form action={handleSubmit}>
<input name="email" type="email" required />
<SubmitButton>提交</SubmitButton>
</form>
);
}
// 完整状态显示
function DetailedStatus() {
const { pending, data, method } = useFormStatus();
return (
<div className="form-status">
{pending && (
<div className="loading">
<span>正在提交...</span>
<div className="spinner" />
</div>
)}
{data && (
<div className="info">
提交方法: {method}
</div>
)}
</div>
);
}useFormState Hook
jsx
import { useFormState } from 'react-dom';
// 定义action
async function createUser(prevState, formData) {
const username = formData.get('username');
const email = formData.get('email');
// 验证
if (!username || username.length < 3) {
return {
success: false,
message: '用户名至少3个字符',
};
}
if (!email.includes('@')) {
return {
success: false,
message: '邮箱格式不正确',
};
}
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
return {
success: true,
message: '用户创建成功!',
data: { username, email },
};
}
function FormWithState() {
const [state, formAction] = useFormState(createUser, {
success: null,
message: '',
});
return (
<form action={formAction}>
<div>
<input name="username" placeholder="用户名" required />
</div>
<div>
<input name="email" type="email" placeholder="邮箱" required />
</div>
<button type="submit">创建用户</button>
{state.message && (
<div className={state.success ? 'success' : 'error'}>
{state.message}
</div>
)}
{state.success && state.data && (
<div className="result">
<p>用户名: {state.data.username}</p>
<p>邮箱: {state.data.email}</p>
</div>
)}
</form>
);
}客户端Actions
基础客户端Action
jsx
function ClientAction() {
const [result, setResult] = useState(null);
async function handleSubmit(formData) {
const username = formData.get('username');
const email = formData.get('email');
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email }),
});
if (!response.ok) {
throw new Error('创建失败');
}
const data = await response.json();
setResult(data);
} catch (error) {
console.error('错误:', error);
setResult({ error: error.message });
}
}
return (
<form action={handleSubmit}>
<input name="username" required />
<input name="email" type="email" required />
<button type="submit">提交</button>
{result && (
<div>
{result.error ? (
<div className="error">{result.error}</div>
) : (
<div className="success">创建成功: {result.id}</div>
)}
</div>
)}
</form>
);
}带验证的客户端Action
jsx
function ValidatedClientAction() {
const [errors, setErrors] = useState({});
function validateForm(formData) {
const errors = {};
const username = formData.get('username');
if (!username || username.length < 3) {
errors.username = '用户名至少3个字符';
}
const email = formData.get('email');
if (!email || !email.includes('@')) {
errors.email = '邮箱格式不正确';
}
const password = formData.get('password');
if (!password || password.length < 8) {
errors.password = '密码至少8个字符';
}
return errors;
}
async function handleSubmit(formData) {
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
// 提交数据
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: formData.get('username'),
email: formData.get('email'),
password: formData.get('password'),
}),
});
if (response.ok) {
alert('注册成功!');
}
}
return (
<form action={handleSubmit}>
<div>
<input name="username" placeholder="用户名" />
{errors.username && (
<span className="error">{errors.username}</span>
)}
</div>
<div>
<input name="email" type="email" placeholder="邮箱" />
{errors.email && (
<span className="error">{errors.email}</span>
)}
</div>
<div>
<input name="password" type="password" placeholder="密码" />
{errors.password && (
<span className="error">{errors.password}</span>
)}
</div>
<button type="submit">注册</button>
</form>
);
}Server Actions
基础Server Action
jsx
// app/actions.js
'use server';
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
// 服务器端验证
if (!title || title.length < 5) {
return {
success: false,
error: '标题至少5个字符',
};
}
// 数据库操作
const post = await db.post.create({
data: { title, content },
});
// 重新验证缓存
revalidatePath('/posts');
return {
success: true,
data: post,
};
}
// app/PostForm.jsx
import { createPost } from './actions';
export default function PostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="标题" required />
<textarea name="content" placeholder="内容" required />
<button type="submit">发布</button>
</form>
);
}带状态的Server Action
jsx
// app/actions.js
'use server';
export async function updateProfile(prevState, formData) {
const name = formData.get('name');
const bio = formData.get('bio');
try {
// 更新用户资料
const updatedUser = await db.user.update({
where: { id: prevState.userId },
data: { name, bio },
});
revalidatePath('/profile');
return {
success: true,
message: '资料更新成功',
user: updatedUser,
};
} catch (error) {
return {
success: false,
message: '更新失败: ' + error.message,
};
}
}
// app/ProfileForm.jsx
'use client';
import { useFormState } from 'react-dom';
import { updateProfile } from './actions';
export default function ProfileForm({ userId, initialData }) {
const [state, formAction] = useFormState(updateProfile, {
userId,
success: null,
message: '',
});
return (
<form action={formAction}>
<input
name="name"
defaultValue={initialData.name}
placeholder="姓名"
/>
<textarea
name="bio"
defaultValue={initialData.bio}
placeholder="个人简介"
/>
<button type="submit">更新资料</button>
{state.message && (
<div className={state.success ? 'success' : 'error'}>
{state.message}
</div>
)}
</form>
);
}文件上传Server Action
jsx
// app/actions.js
'use server';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export async function uploadFile(formData) {
const file = formData.get('file');
if (!file) {
return { success: false, error: '请选择文件' };
}
// 验证文件类型
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return { success: false, error: '只允许上传图片文件' };
}
// 验证文件大小 (5MB)
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: '文件大小不能超过5MB' };
}
try {
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// 生成唯一文件名
const filename = `${Date.now()}-${file.name}`;
const path = join(process.cwd(), 'public', 'uploads', filename);
await writeFile(path, buffer);
return {
success: true,
url: `/uploads/${filename}`,
};
} catch (error) {
return {
success: false,
error: '上传失败: ' + error.message,
};
}
}
// app/UploadForm.jsx
'use client';
import { useFormState } from 'react-dom';
import { uploadFile } from './actions';
export default function UploadForm() {
const [state, formAction] = useFormState(uploadFile, {
success: null,
url: null,
});
return (
<form action={formAction}>
<input
type="file"
name="file"
accept="image/*"
required
/>
<button type="submit">上传</button>
{state.error && (
<div className="error">{state.error}</div>
)}
{state.success && state.url && (
<div className="success">
<p>上传成功!</p>
<img src={state.url} alt="上传的图片" />
</div>
)}
</form>
);
}乐观更新
基础乐观更新
jsx
'use client';
import { useOptimistic } from 'react';
import { addTodo } from './actions';
export default function TodoList({ initialTodos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state, newTodo) => [...state, { ...newTodo, pending: true }]
);
async function handleSubmit(formData) {
const text = formData.get('text');
// 乐观更新UI
addOptimisticTodo({
id: Date.now(),
text,
completed: false,
});
// 实际提交
await addTodo(formData);
}
return (
<div>
<form action={handleSubmit}>
<input name="text" placeholder="添加待办事项" required />
<button type="submit">添加</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} className={todo.pending ? 'pending' : ''}>
{todo.text}
</li>
))}
</ul>
</div>
);
}复杂乐观更新
jsx
'use client';
import { useOptimistic } from 'react';
export default function PostList({ initialPosts }) {
const [optimisticPosts, updateOptimisticPosts] = useOptimistic(
initialPosts,
(state, { action, post }) => {
switch (action) {
case 'add':
return [...state, { ...post, optimistic: true }];
case 'delete':
return state.filter(p => p.id !== post.id);
case 'update':
return state.map(p =>
p.id === post.id ? { ...p, ...post, optimistic: true } : p
);
default:
return state;
}
}
);
async function handleAdd(formData) {
const title = formData.get('title');
const content = formData.get('content');
const newPost = {
id: Date.now(),
title,
content,
createdAt: new Date(),
};
updateOptimisticPosts({ action: 'add', post: newPost });
await createPost(formData);
}
async function handleDelete(postId) {
updateOptimisticPosts({ action: 'delete', post: { id: postId } });
await deletePost(postId);
}
async function handleUpdate(postId, formData) {
const title = formData.get('title');
const content = formData.get('content');
updateOptimisticPosts({
action: 'update',
post: { id: postId, title, content },
});
await updatePost(postId, formData);
}
return (
<div>
<form action={handleAdd}>
<input name="title" placeholder="标题" required />
<textarea name="content" placeholder="内容" required />
<button type="submit">发布</button>
</form>
<div className="posts">
{optimisticPosts.map(post => (
<article key={post.id} className={post.optimistic ? 'optimistic' : ''}>
<h2>{post.title}</h2>
<p>{post.content}</p>
<button onClick={() => handleDelete(post.id)}>删除</button>
</article>
))}
</div>
</div>
);
}错误处理
错误边界
jsx
'use client';
import { useFormState } from 'react-dom';
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div className="error-boundary">
<h2>出错了</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>重试</button>
</div>
);
}
export default function FormWithErrorBoundary() {
async function handleSubmit(prevState, formData) {
// 可能抛出错误的操作
const result = await riskyOperation(formData);
if (!result.success) {
throw new Error(result.error);
}
return result;
}
const [state, formAction] = useFormState(handleSubmit, null);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<form action={formAction}>
<input name="data" required />
<button type="submit">提交</button>
</form>
</ErrorBoundary>
);
}错误状态处理
jsx
'use client';
import { useFormState } from 'react-dom';
async function submitForm(prevState, formData) {
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
return {
success: false,
errors: error.errors || {},
message: error.message,
};
}
const data = await response.json();
return {
success: true,
data,
};
} catch (error) {
return {
success: false,
message: '网络错误: ' + error.message,
};
}
}
export default function FormWithErrorHandling() {
const [state, formAction] = useFormState(submitForm, {
success: null,
errors: {},
message: '',
});
return (
<form action={formAction}>
<div>
<input name="username" />
{state.errors?.username && (
<span className="error">{state.errors.username}</span>
)}
</div>
<div>
<input name="email" type="email" />
{state.errors?.email && (
<span className="error">{state.errors.email}</span>
)}
</div>
<button type="submit">提交</button>
{state.message && (
<div className={state.success ? 'success' : 'error'}>
{state.message}
</div>
)}
</form>
);
}实战案例
完整的用户注册表单
jsx
// app/actions.js
'use server';
import { z } from 'zod';
import { hash } from 'bcryptjs';
import { db } from '@/lib/db';
import { redirect } from 'next/navigation';
const registerSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: '密码不匹配',
path: ['confirmPassword'],
});
export async function register(prevState, formData) {
// 验证
const validatedFields = registerSchema.safeParse({
username: formData.get('username'),
email: formData.get('email'),
password: formData.get('password'),
confirmPassword: formData.get('confirmPassword'),
});
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
message: '验证失败',
};
}
const { username, email, password } = validatedFields.data;
// 检查用户名是否存在
const existingUser = await db.user.findFirst({
where: {
OR: [
{ username },
{ email },
],
},
});
if (existingUser) {
return {
success: false,
message: existingUser.username === username
? '用户名已被使用'
: '邮箱已被注册',
};
}
// 创建用户
const hashedPassword = await hash(password, 10);
try {
await db.user.create({
data: {
username,
email,
password: hashedPassword,
},
});
// 重定向到登录页
redirect('/login');
} catch (error) {
return {
success: false,
message: '注册失败,请稍后重试',
};
}
}
// app/register/page.jsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { register } from '../actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '注册中...' : '注册'}
</button>
);
}
export default function RegisterPage() {
const [state, formAction] = useFormState(register, {
success: null,
errors: {},
message: '',
});
return (
<div className="register-page">
<h1>用户注册</h1>
<form action={formAction}>
<div className="form-group">
<label>用户名</label>
<input
name="username"
placeholder="用户名"
required
/>
{state.errors?.username && (
<span className="error">{state.errors.username[0]}</span>
)}
</div>
<div className="form-group">
<label>邮箱</label>
<input
name="email"
type="email"
placeholder="邮箱"
required
/>
{state.errors?.email && (
<span className="error">{state.errors.email[0]}</span>
)}
</div>
<div className="form-group">
<label>密码</label>
<input
name="password"
type="password"
placeholder="密码"
required
/>
{state.errors?.password && (
<span className="error">{state.errors.password[0]}</span>
)}
</div>
<div className="form-group">
<label>确认密码</label>
<input
name="confirmPassword"
type="password"
placeholder="确认密码"
required
/>
{state.errors?.confirmPassword && (
<span className="error">{state.errors.confirmPassword[0]}</span>
)}
</div>
<SubmitButton />
{state.message && !state.success && (
<div className="error-message">{state.message}</div>
)}
</form>
</div>
);
}带图片上传的博客文章表单
jsx
// app/actions.js
'use server';
import { z } from 'zod';
import { put } from '@vercel/blob';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
const postSchema = z.object({
title: z.string().min(5).max(100),
content: z.string().min(10),
category: z.string(),
});
export async function createPost(prevState, formData) {
// 验证文本字段
const validatedFields = postSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
category: formData.get('category'),
});
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
};
}
// 处理图片上传
const image = formData.get('image');
let imageUrl = null;
if (image && image.size > 0) {
// 验证文件类型和大小
if (!image.type.startsWith('image/')) {
return {
success: false,
message: '只能上传图片文件',
};
}
if (image.size > 5 * 1024 * 1024) {
return {
success: false,
message: '图片大小不能超过5MB',
};
}
// 上传到Blob存储
const blob = await put(image.name, image, {
access: 'public',
});
imageUrl = blob.url;
}
// 创建文章
try {
const post = await db.post.create({
data: {
...validatedFields.data,
imageUrl,
authorId: 1, // 从session获取
},
});
revalidatePath('/posts');
return {
success: true,
data: post,
};
} catch (error) {
return {
success: false,
message: '创建失败: ' + error.message,
};
}
}
// app/posts/new/page.jsx
'use client';
import { useFormState } from 'react-dom';
import { createPost } from '@/app/actions';
import { useState } from 'react';
export default function NewPostPage() {
const [state, formAction] = useFormState(createPost, {
success: null,
errors: {},
});
const [preview, setPreview] = useState(null);
const handleImageChange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result);
};
reader.readAsDataURL(file);
}
};
return (
<div className="new-post-page">
<h1>创建文章</h1>
<form action={formAction}>
<div className="form-group">
<label>标题</label>
<input
name="title"
placeholder="文章标题"
required
/>
{state.errors?.title && (
<span className="error">{state.errors.title[0]}</span>
)}
</div>
<div className="form-group">
<label>分类</label>
<select name="category" required>
<option value="">选择分类</option>
<option value="tech">技术</option>
<option value="life">生活</option>
<option value="travel">旅行</option>
</select>
{state.errors?.category && (
<span className="error">{state.errors.category[0]}</span>
)}
</div>
<div className="form-group">
<label>封面图片</label>
<input
name="image"
type="file"
accept="image/*"
onChange={handleImageChange}
/>
{preview && (
<img src={preview} alt="预览" className="preview" />
)}
</div>
<div className="form-group">
<label>内容</label>
<textarea
name="content"
rows={10}
placeholder="文章内容"
required
/>
{state.errors?.content && (
<span className="error">{state.errors.content[0]}</span>
)}
</div>
<button type="submit">发布文章</button>
{state.message && (
<div className={state.success ? 'success' : 'error'}>
{state.message}
</div>
)}
{state.success && (
<div className="success">
文章创建成功! <a href="/posts">查看文章列表</a>
</div>
)}
</form>
</div>
);
}总结
React 19 Form Actions要点:
- 简化代码:无需手动管理loading和error状态
- useFormStatus:访问表单提交状态
- useFormState:管理表单状态和结果
- Server Actions:服务器端表单处理
- 乐观更新:useOptimistic提升用户体验
- 错误处理:统一的错误处理机制
Form Actions是React 19的重大改进,显著简化了表单处理流程。