Chapter 04

Action 数据变更

理解 Remix 表单提交的完整流程,实现无 JS 降级、验证反馈与乐观 UI

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 中处理多种操作。