4.1 Action 基础
Action 是路由的数据变更函数,与 Loader 对应(读 vs 写)。当 HTML Form 以 POST 方式提交到当前路由时,Remix 会调用该路由的 action 函数。Action 在服务端执行,接收原生 Request 对象,可操作数据库、发送邮件、调用第三方 API。
渐进增强核心Remix 的 <Form> 组件在有 JavaScript 时拦截提交做 fetch(无页面刷新),在无 JavaScript 时退化为标准 HTML Form 的 POST 提交(整页刷新)。两种情况 Action 都会被正确调用,这就是渐进增强。
// app/routes/contact.tsx
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
// action 处理 POST /contact
export async function action({ request }: ActionFunctionArgs) {
// 从 request 读取表单数据(原生 FormData)
const formData = await request.formData();
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
// 验证
const errors: Record<string, string> = {};
if (!name) errors.name = "姓名不能为空";
if (!email.includes("@")) errors.email = "邮箱格式不正确";
if (message.length < 10) errors.message = "留言至少10个字符";
// 有错误 → 返回错误数据,不跳转
if (Object.keys(errors).length) {
return json({ errors, values: { name, email, message } }, { status: 422 });
}
// 写数据库
await createMessage({ name, email, message });
// 成功 → 重定向(POST/Redirect/GET 模式)
return redirect("/contact/success");
}
export default function ContactPage() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<div>
<label>姓名</label>
<input name="name" defaultValue={actionData?.values?.name} />
{actionData?.errors?.name && (
<p style={{ color: "red" }}>{actionData.errors.name}</p>
)}
</div>
<div>
<label>邮箱</label>
<input name="email" type="email" defaultValue={actionData?.values?.email} />
{actionData?.errors?.email && (
<p style={{ color: "red" }}>{actionData.errors.email}</p>
)}
</div>
<div>
<label>留言</label>
<textarea name="message" defaultValue={actionData?.values?.message} />
{actionData?.errors?.message && (
<p style={{ color: "red" }}>{actionData.errors.message}</p>
)}
</div>
<button type="submit">发送留言</button>
</Form>
);
}
TSX
4.2 POST/Redirect/GET 模式
Action 成功后通过 redirect() 跳转,避免用户刷新页面时重复提交表单(浏览器的双重提交问题)。这是 Web 开发中的经典模式,Remix 让它变得极其简单。
// ✅ 正确:成功后重定向
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const post = await createPost(Object.fromEntries(form));
return redirect(`/blog/${post.slug}`);
}
// ❌ 错误:成功但返回 json,刷新会重复提交
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
await createPost(Object.fromEntries(form));
return json({ success: true }); // 避免这样做
}
TSX
4.3 乐观 UI — useNavigation
乐观 UI 是指在服务端操作完成前,就先在 UI 上呈现预期结果,让用户感受到即时响应。Remix 通过 useNavigation Hook 提供当前导航/提交状态,实现乐观 UI 非常直观。
import { Form, useNavigation } from "@remix-run/react";
export default function NewPostForm() {
const navigation = useNavigation();
// navigation.state: "idle" | "submitting" | "loading"
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<input name="title" placeholder="文章标题" disabled={isSubmitting} />
<textarea name="content" disabled={isSubmitting} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "发布中..." : "发布文章"}
</button>
</Form>
);
}
TSX
4.4 intent 字段 — 一个路由多个 Action
一个路由只能有一个 action 函数,但一个页面可能需要多个操作(如"保存草稿"和"发布")。通过在 Form 中添加隐藏 intent 字段区分不同意图。
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const intent = form.get("intent");
switch (intent) {
case "save-draft":
await saveDraft(Object.fromEntries(form));
return json({ message: "草稿已保存" });
case "publish":
const post = await publishPost(Object.fromEntries(form));
return redirect(`/blog/${post.slug}`);
default:
throw new Response("未知操作", { status: 400 });
}
}
// 对应的 JSX
<Form method="post">
<input name="title" />
<textarea name="content" />
<button name="intent" value="save-draft">保存草稿</button>
<button name="intent" value="publish">发布</button>
</Form>
TSX
4.5 实战:评论提交完整流程
在博客文章页面添加评论功能,演示 Action + 验证 + 乐观 UI 的完整组合。
// app/routes/blog.$slug.tsx(扩展:加入评论功能)
import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
const [post, comments] = await Promise.all([
getPost(params.slug!),
getComments(params.slug!),
]);
if (!post) throw new Response("Not Found", { status: 404 });
return json({ post, comments });
}
export async function action({ request, params }: ActionFunctionArgs) {
const form = await request.formData();
const author = form.get("author") as string;
const body = form.get("body") as string;
const errors: Record<string, string> = {};
if (!author) errors.author = "请填写昵称";
if (body.length < 5) errors.body = "评论太短了";
if (Object.keys(errors).length) {
return json({ errors }, { status: 422 });
}
await createComment({ postSlug: params.slug!, author, body });
return redirect(`/blog/${params.slug}#comments`);
}
export default function BlogPost() {
const { post, comments } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<div>
<h1>{post.title}</h1>
<div>{post.content}</div>
<section id="comments">
<h2>评论({comments.length})</h2>
{comments.map(c => (
<div key={c.id}>
<strong>{c.author}</strong>
<p>{c.body}</p>
</div>
))}
<Form method="post">
<input name="author" placeholder="昵称" />
{actionData?.errors?.author && <p>{actionData.errors.author}</p>}
<textarea name="body" placeholder="写下你的评论..." />
{actionData?.errors?.body && <p>{actionData.errors.body}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "提交中..." : "发布评论"}
</button>
</Form>
</section>
</div>
);
}
TSX
本章小结Action 是 Remix 处理数据变更的机制:从 request.formData() 读取原生 FormData;验证失败返回 json({ errors }),组件用 useActionData 获取并展示错误;成功后 redirect() 跳转避免重复提交;useNavigation().state 实现提交状态反馈;通过 intent 字段在单个 Action 中处理多种操作。