停止你的 Redux 事故:React 19 Actions 与 NestJS 的“裸奔”式表单统治
各位老铁,欢迎来到这场关于“如何不用手动维护状态”的讲座。
在过去的几年里,前端界就像一个发了疯的装修队。为了填一个简单的登录表单,我们要引入 Redux、Zustand、Context API,甚至还要为了处理表单验证而写一堆样板代码。我们就像是在给一个穿裤子的人穿了十层秋裤——看着臃肿,跑起来绊脚,而且关键时刻,这裤子居然还会走光。
直到 React 19 出现了。它就像是一个穿着紧身衣的健美教练,手里拿着一把名为 createServerAction 的瑞士军刀,告诉你:“够了,别他妈再自己造轮子了。”
今天,我们要聊聊怎么用 React 19 Actions 和 NestJS 控制器,通过最原始的 HTTP 协议,把全栈表单状态管理的复杂性变成一场优雅的华尔兹。
第一部分:别再写“回调地狱”了,那是上个世纪的事
让我们先来看看曾经的日子,也就是所谓的“旧时代”做法。那时候,当你想提交一个表单,你脑子里得浮现出下面这幅画面:
// ❌ 过去的你:精神崩溃的代码
const [formData, setFormData] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
// 你得手动拼 URL,手动处理 loading,手动处理错误
const response = await axios.post('/api/login', formData);
if (response.data.success) {
// 成功后的逻辑...
} else {
// 失败逻辑...
}
} catch (err) {
// 异常捕获逻辑...
} finally {
setLoading(false);
}
};
看这段代码,是不是感觉像是在便秘?每提交一次,你得手动管理 loading 的开始和结束,还得手动处理错误对象。如果你有 10 个表单,你就得复制粘贴 10 次这种“屎山代码”。
React 19 Actions 的出现,就是为了终结这种苦役。
Actions 是什么?简单说,它就是一个运行在服务器上的异步函数,但 React 给它加了一个神奇的外挂。当你调用这个函数时,React 会自动帮你处理 HTTP 请求的细节,包括 loading 状态、错误处理、数据缓存,甚至重定向。
第二部分:NestJS 控制器——那个严肃但靠谱的后卫
既然前端要把活儿揽过来,那后端呢?后端不能只负责收钱(响应),还得负责守门(验证)。
在这个全栈架构中,NestJS 扮演的是“严厉的守门员”角色。它不会接受任何不符合规则的球(数据)。我们要做的,就是把 React 的 Actions 和 NestJS 的 Controller 连接起来,通过标准的 HTTP 协议传输数据。
1. 定义契约
首先,我们需要一个 DTO (Data Transfer Object)。这就像是外卖订单上的标准格式,必须严格遵守。
// nestjs/auth.dto.ts
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail({}, { message: '这邮箱写得像乱码吧?' })
email: string;
@IsNotEmpty()
@MinLength(6, { message: '密码短了点,安全系数不够' })
password: string;
}
2. 控制器逻辑
然后,在 NestJS 的 Controller 中,我们定义一个处理函数。注意,这里没有复杂的 middleware 逻辑,我们直接用 ValidationPipe,让 NestJS 帮我们检查数据。
// nestjs/auth.controller.ts
import { Controller, Post, Body, UseGuards, ValidationPipe } from '@nestjs/common';
import { AuthService } from './auth.service';
@Controller('api/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
// ValidationPipe 会自动把 body 解析成 LoginDto 实例
// 如果验证失败,会直接抛出异常,不需要你手动判断
async login(@Body(ValidationPipe) loginDto: LoginDto) {
return this.authService.login(loginDto);
}
}
这就是 NestJS 的核心魅力:声明式编程。你告诉它要做什么(POST 请求),它告诉你能不能做(ValidationPipe)。如果 NestJS 返回 400 错误,React Actions 会自动捕获这个异常并告诉你“密码太短了”。
第三部分:React 19 Actions——服务器端的表单逻辑
好了,现在我们有了后端,接下来就是前端的重头戏。React 19 引入了 createServerAction。注意,这不是一个普通的 async 函数,它是一个“Action”。
这个 Action 在 React 内部会生成一个对象,这个对象可以像 props 一样传给组件。而且,它自带了一个 state 管理器。
让我们来看看怎么写这个 Action。
// components/auth-form.tsx
'use server'; // 关键!这告诉 React "我是在服务器运行的"
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
// 引入我们的 NestJS 控制器处理函数(这里用模拟数据演示,实际是 fetch)
// 我们不直接调用 NestJS 的实例,而是通过 HTTP 请求调用它
async function loginAction(formData: FormData) {
const email = formData.get('email');
const password = formData.get('password');
const response = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
// 如果服务器返回错误,我们抛出一个 Error
// React 19 会自动识别这个错误并处理 UI
throw new Error(data.message || '登录失败');
}
// 登录成功,设置 Cookie 并跳转
cookies().set('session-token', data.token, { httpOnly: true });
redirect('/dashboard');
}
// 这是一个组件
export function LoginForm() {
return (
<form action={loginAction}>
<input name="email" type="email" placeholder="你的邮箱" required />
<input name="password" type="password" placeholder="你的密码" required />
<button type="submit">
{/* React 19 会自动处理这里的 loading 状态,你不用写任何 JS 逻辑! */}
登录
</button>
</form>
);
}
看到了吗?这个组件的代码干净得就像刚洗完澡。action={loginAction} 一行代码,就把整个表单生命周期托管给了 React。你甚至不需要写 loading 状态,当你点击按钮时,按钮会自动变灰并显示“加载中”。
第四部分:深入理解 useFormState 和 useFormStatus
上面的例子是最基础的。但在真实场景中,你可能需要在提交成功后更新页面上的其他状态,或者显示更精细的错误信息。这时候,就需要用到 React 19 的两个新 Hook:useFormState 和 useFormStatus。
1. useFormState:管理提交后的状态
假设登录成功后,我们需要在页面顶部显示一个欢迎语,或者从服务器获取用户信息。
'use client'; // 这个组件需要客户端交互来读取状态
import { useFormState, useFormStatus } from 'react-dom';
// 定义状态类型:'idle' | 'loading' | 'success' | 'error'
type FormState = {
status: 'idle' | 'loading' | 'success' | 'error';
message?: string;
user?: any;
};
export function AdvancedLoginForm() {
// 初始状态
const initialState: FormState = { status: 'idle' };
// 这里的第二个参数是当前的 state,第一个参数是 action 函数
const [state, formAction] = useFormState(loginAction, initialState);
return (
<form action={formAction}>
<input name="email" type="email" placeholder="邮箱" required />
<input name="password" type="password" placeholder="密码" required />
{/* 表单按钮 */}
<FormButton />
{/* 状态反馈区域 */}
<StatusMessage status={state.status} message={state.message} />
</form>
);
}
// 子组件:处理按钮的 loading 状态
function FormButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '正在为你登录...' : '点击登录'}
</button>
);
}
// 子组件:展示消息
function StatusMessage({ status, message }: any) {
if (status === 'loading') return <p className="text-blue-500">正在连接服务器...</p>;
if (status === 'success') return <p className="text-green-500">欢迎回来,大佬!</p>;
if (status === 'error') return <p className="text-red-500">哎呀,{message},请重试。</p>;
return null;
}
这里发生了一个神奇的化学反应:Action 函数被挂载到了 <form> 标签上。当表单提交时,React 会自动拦截这个事件,调用 loginAction,并把 FormData 传给它。函数执行完毕后,它返回的 state 会被自动传递给 useFormState,触发组件重新渲染。
这就是 React 19 的“状态机”模式。表单本身就是一个状态机:空闲 -> 提交中 -> 成功/失败 -> 空闲。
第五部分:数据验证的双重保险
在 React 19 中,验证不仅在前端做,还在后端做。但 React 19 给了前端验证一种“特权”。
你可以在提交前先做验证,如果前端验证不通过,React 会自动抛出错误,根本不会把请求发给 NestJS。这大大减少了服务器的负担。
客户端验证示例
async function loginAction(formData: FormData) {
const email = formData.get('email');
const password = formData.get('password');
// 1. 前端验证:快速失败
if (!email || !email.includes('@')) {
// 这里的 throw Error 会被 React 捕获
throw new Error('这看起来不像个邮箱,老铁。');
}
// 2. 后端验证:不可篡改
const response = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
// 如果服务器返回错误,覆盖前端的错误信息
throw new Error(data.message);
}
redirect('/dashboard');
}
第六部分:处理复杂表单与文件上传
当然,现实世界的表单往往很复杂。比如一个“创建新用户”的表单,包含姓名、角色、头像上传。React 19 和 NestJS 配合得天衣无缝。
React 端:文件上传
React 19 默认支持 <input type="file" />。
'use client';
import { useFormState } from 'react-dom';
export function CreateUserForm() {
const [state, formAction] = useFormState(createUserAction, { status: 'idle' });
return (
<form action={formAction} encType="multipart/form-data">
<input name="name" type="text" placeholder="姓名" required />
<select name="role">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
{/* 文件上传不需要额外处理,FormData 会自动处理二进制数据 */}
<input name="avatar" type="file" accept="image/*" required />
<button type="submit">创建用户</button>
</form>
);
}
NestJS 端:接收文件
NestJS 使用 Multer 来处理文件上传。
import { Controller, Post, Body, UseGuards, UseInterceptors, UploadedFile, ValidationPipe } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
@Controller('api/users')
export class UsersController {
@Post()
@UseInterceptors(FileInterceptor('avatar', {
storage: diskStorage({
destination: './uploads',
filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname),
}),
}))
async createUser(
@Body(ValidationPipe) createUserDto: CreateUserDto,
@UploadedFile() file: Express.Multer.File
) {
// NestJS 会自动将 'avatar' 字段映射为 file 对象
console.log(file.originalname);
return this.userService.create(createUserDto, file);
}
}
注意看这个 name="avatar"。在 React 的 <input type="file" name="avatar" /> 中定义的 name,必须与 NestJS 中 @UploadedFile 装饰器后面括号里的参数 ('avatar') 完全一致。这种命名一致性是 HTTP 协议的基础,非常直观,不会出错。
第七部分:乐观 UI —— 告别加载转圈圈的等待焦虑
这可能是 React 19 Actions 最令人兴奋的功能了。现在的用户体验中,点击按钮 -> 显示 loading -> 等待 2 秒 -> 显示成功,这中间的 2 秒是非常枯燥的。
React 19 允许你做乐观更新:在服务器还没响应之前,直接更新 UI。
乐观更新的实现
'use client';
import { useState } from 'react';
import { useFormState } from 'react-dom';
// 假设这是一个模拟的 API 调用,带有 2 秒延迟
async function likePostAction(formData: FormData) {
await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟网络延迟
const postId = formData.get('postId');
const action = formData.get('action'); // like 或 unlike
// 真正的后端逻辑...
return { success: true, postId };
}
export function LikeButton({ postId, initialLiked }: any) {
const [liked, setLiked] = useState(initialLiked);
const [state, formAction] = useFormState(likePostAction, { status: 'idle' });
// 这是一个包装函数,用于实现乐观 UI
const handleLike = () => {
// 1. 先更新本地状态(乐观)
setLiked(true);
// 2. 提交表单
// 注意:React 19 的 Action 会先执行 handleLike 里的逻辑吗?
// 不,React 19 的机制是:你调用 action,它会等待服务器返回。
// 但我们可以利用 useEffect 或者 useState 的副作用来模拟。
// 这里演示一个简化的乐观 UI 逻辑:
const formData = new FormData();
formData.append('postId', postId);
formData.append('action', 'like');
formAction(formData).then(() => {
// 如果服务器成功了,保持乐观状态
// 如果服务器失败了,我们有一个 hook 可以撤销
});
};
// 这是一个状态钩子,用于检测是否应该撤销乐观更新
const { pending } = useFormStatus();
return (
<form action={handleLike}>
<button type="submit" disabled={pending || liked}>
{liked ? '❤️ 已点赞' : '🤍 点赞'}
</button>
</form>
);
}
实际上,React 19 有更优雅的 useOptimistic Hook。让我们看看 useOptimistic 是怎么写的。
import { useOptimistic, useFormState } from 'react-dom';
function LikeButton({ postId, initialLiked }: any) {
const [liked, setLiked] = useState(initialLiked);
// 1. 定义乐观状态:如果当前是未点赞,乐观状态就是点赞
const [optimisticLiked, addOptimisticState] = useOptimistic(
liked,
// 这是一个 reducer 函数,告诉你如何转换状态
(state, newValue: boolean) => newValue
);
const [state, formAction] = useFormState(likePostAction, { status: 'idle' });
const handleSubmit = async () => {
// 2. 调用 addOptimisticState 立即更新 UI
addOptimisticState(true);
// 3. 提交表单
const formData = new FormData();
formData.append('postId', postId);
formData.append('action', 'like');
// formAction 会等待 Promise resolve
await formAction(formData);
// 如果成功,setLiked 会被 formAction 返回的数据更新(如果有的话)
// 但在这个简单例子里,我们主要依靠 optimistic
};
return (
<button onClick={handleSubmit} disabled={state.pending}>
{optimisticLiked ? '❤️ 已点赞' : '🤍 点赞'}
</button>
);
}
哇,这代码简洁得令人发指。用户点击按钮 -> UI 瞬间变成红色爱心 -> 2秒后服务器确认 -> 无变化(因为本来就是对的)。
如果服务器出错了怎么办?useFormState 会把错误状态带回来,useOptimistic 会自动退回到真实状态。用户完全感觉不到刚才那一瞬间的“假象”,体验丝滑得像黄油。
第八部分:服务器组件与 Actions 的完美配合
这是 React 19 全栈开发的核心:React Server Components (RSC)。
你可以把大部分组件写成 Server Component。Server Component 不需要 use client,它们天生就能直接调用 Actions,甚至直接从数据库读取数据,而不需要经过 HTTP 请求。
// app/dashboard/page.tsx
// 这是一个 Server Component,默认运行在服务器上
// 它可以直接调用 Actions,而不需要客户端拦截
import { redirect } from 'next/navigation';
import { loginAction } from './auth-actions'; // 假设这个文件在 server 目录下
export default function DashboardPage() {
return (
<div>
<h1>欢迎来到 Dashboard</h1>
<p>这里是纯服务端渲染的内容。</p>
<form action={loginAction}>
<h2>快速登录</h2>
<input name="email" type="email" />
<input name="password" type="password" />
<button type="submit">登录</button>
</form>
</div>
);
}
注意,在 Server Component 中,<form action={loginAction}> 是合法的。React 19 会自动把这个 Action 挂载到表单上。当用户提交表单时,React 会生成一个 HTTP POST 请求,转发给 NestJS 控制器。
这意味着,你不需要写任何客户端 JavaScript 来处理表单逻辑!整个流程(数据库查询 -> 验证 -> Action 处理 -> UI 渲染)都在服务端完成了。
第九部分:错误处理与边界
在旧世界中,错误处理就像是在玩扫雷,不知道哪里会炸。
在 React 19 中,我们可以使用 ErrorBoundary 或者更高级的 notFound() 函数。
假设 NestJS 抛出了一个未处理的异常(比如数据库断连),NestJS 会返回 500 错误。React Action 会捕获这个错误。我们可以把这个错误展示给用户。
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { useEffect } from 'react';
export function LoginWithErrorHandling() {
const [state, formAction] = useFormState(loginAction, { status: 'idle' });
// 如果状态是 error,我们可以在这里做特殊处理
useEffect(() => {
if (state.status === 'error') {
console.error('Server Error:', state.message);
// 这里可以触发全局通知,比如 Toast
}
}, [state]);
return (
<form action={formAction}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<FormButton />
{state.status === 'error' && <p className="text-red-500">{state.message}</p>}
</form>
);
}
而且,如果 NestJS 的错误没有被正确捕获,React 还支持捕获“组件错误”,防止整个页面崩溃。
第十部分:架构总结与“实战”建议
现在,让我们把这堆东西理一理。你现在的技术栈应该长这样:
- 前端 (Next.js App Router):
- 使用
createServerAction定义业务逻辑。 - 使用
useFormState和useOptimistic管理状态。 - 使用
<form action={...}>和<input type="file">。 - Server Components 负责布局和展示,Client Components 负责交互(如果需要)。
- 使用
- 后端 (NestJS):
- 使用
Controller接收请求。 - 使用
ValidationPipe(DTO) 进行数据清洗和验证。 - 使用
Multer处理文件上传。 - 使用 Guard 和 Interceptors 进行权限控制和日志记录。
- 使用
- 通信:
- 纯粹的 HTTP (POST/GET)。
- JSON Body (文本)。
- FormData (文件)。
专家建议:
- 不要过度封装: React 19 Actions 已经封装得很好了,不要为了图新鲜去再包一层 API Service。直接在组件里写 Action。
- 善用 Zod: 如果你想在 NestJS 和 React 之间共享验证逻辑,可以使用 Zod。NestJS 的
class-validator和 Zod 的类型提示可以完美配合。 - 乐观更新是必须的: 除非你的表单提交需要几秒钟,否则一定要用
useOptimistic。这是用户体验的质变。 - 不要在 Actions 里操作 DOM: Actions 运行在服务器上(或者构建后的函数中),没有浏览器 DOM。它只负责数据转换和请求转发。
结语:回归本质
我曾经看过一个很形象的比喻:表单就是用户在和你对话。
在 React 19 之前,你像个聋哑人,你得自己把对话翻译成 HTTP 请求,还得自己听懂对方的回话,还得自己更新 UI 的表情。
现在,React 19 Actions 和 NestJS 配合,就像是把你变成了一个外交官。你只要开口说话(定义表单),NestJS 就是你的翻译官和保镖,帮你检查对方说的话是不是脏话(验证),帮你把话传出去(请求),回来后把结果翻译成你能听懂的语言(State),并自动调整你的表情(UI 更新)。
这不仅仅是技术栈的升级,这是一种思维方式的解放。我们终于可以扔掉那些繁琐的 State Management 库,把精力花在构建真正的业务逻辑上。
所以,去拥抱 React 19 Actions 吧,去拥抱 NestJS,去享受那种代码少、逻辑清晰、用户体验极佳的开发过程。这就是全栈开发的真谛。