Remix表单处理 #

一、表单概述 #

Remix 的表单处理基于原生 HTML 表单,同时提供了增强功能。即使 JavaScript 加载失败,表单仍然可以工作。

二、Form组件 #

2.1 基本用法 #

tsx
import { Form } from "@remix-run/react";

export default function Contact() {
  return (
    <Form method="post">
      <div>
        <label htmlFor="name">姓名</label>
        <input id="name" name="name" type="text" required />
      </div>
      <div>
        <label htmlFor="email">邮箱</label>
        <input id="email" name="email" type="email" required />
      </div>
      <div>
        <label htmlFor="message">消息</label>
        <textarea id="message" name="message" required />
      </div>
      <button type="submit">发送</button>
    </Form>
  );
}

2.2 Form属性 #

属性 说明
method HTTP方法:get, post, put, patch, delete
action 提交目标URL
encType 编码类型:multipart/form-data, application/json
replace 替换历史记录而非添加
preventScrollReset 阻止滚动重置
navigate 是否导航到新页面

2.3 表单方法 #

tsx
// 创建
<Form method="post">
  <button type="submit">创建</button>
</Form>

// 更新
<Form method="put">
  <button type="submit">更新</button>
</Form>

// 删除
<Form method="delete">
  <button type="submit">删除</button>
</Form>

三、表单状态 #

3.1 提交状态 #

tsx
import { Form, useNavigation } from "@remix-run/react";

export default function CreatePost() {
  const navigation = useNavigation();
  
  const isSubmitting = navigation.state === "submitting";
  const isCreatingPost = 
    navigation.state === "submitting" &&
    navigation.formMethod === "POST" &&
    navigation.formAction === "/posts/new";
  
  return (
    <Form method="post">
      <input name="title" disabled={isSubmitting} />
      <textarea name="content" disabled={isSubmitting} />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "创建中..." : "创建文章"}
      </button>
    </Form>
  );
}

3.2 表单数据访问 #

tsx
import { useNavigation } from "@remix-run/react";

export default function Form() {
  const navigation = useNavigation();
  
  // 访问正在提交的表单数据
  const formData = navigation.formData;
  const title = formData?.get("title");
  
  return (
    <Form method="post">
      <input name="title" defaultValue={title} />
      <button type="submit">提交</button>
    </Form>
  );
}

四、useFetcher #

4.1 基本用法 #

useFetcher 用于不导航的表单提交:

tsx
import { useFetcher } from "@remix-run/react";

export default function Newsletter() {
  const fetcher = useFetcher();
  
  return (
    <fetcher.Form method="post" action="/api/newsletter">
      <input name="email" type="email" required />
      <button type="submit">
        {fetcher.state === "submitting" ? "订阅中..." : "订阅"}
      </button>
    </fetcher.Form>
  );
}

4.2 状态管理 #

tsx
import { useFetcher } from "@remix-run/react";

export default function LikeButton({ postId }: { postId: string }) {
  const fetcher = useFetcher();
  
  const isLiked = fetcher.formData?.get("liked") === "true";
  
  return (
    <fetcher.Form method="post" action={`/posts/${postId}/like`}>
      <input type="hidden" name="liked" value={isLiked ? "false" : "true"} />
      <button type="submit">
        {isLiked ? "取消点赞" : "点赞"}
      </button>
    </fetcher.Form>
  );
}

4.3 数据加载 #

tsx
import { useFetcher } from "@remix-run/react";

export default function Search() {
  const fetcher = useFetcher<typeof loader>();
  
  return (
    <div>
      <fetcher.Form method="get" action="/api/search">
        <input 
          name="q" 
          onChange={(e) => {
            if (e.target.value.length > 2) {
              fetcher.submit(e.target.form);
            }
          }}
        />
      </fetcher.Form>
      
      {fetcher.state === "loading" && <div>搜索中...</div>}
      
      {fetcher.data?.results && (
        <ul>
          {fetcher.data.results.map((item) => (
            <li key={item.id}>{item.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

五、表单验证 #

5.1 服务端验证 #

tsx
import { json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;
  
  const errors: Record<string, string> = {};
  
  if (!title || title.length < 3) {
    errors.title = "标题至少需要3个字符";
  }
  
  if (!content || content.length < 10) {
    errors.content = "内容至少需要10个字符";
  }
  
  if (Object.keys(errors).length > 0) {
    return json({ errors }, { status: 400 });
  }
  
  const post = await createPost({ title, content });
  return redirect(`/posts/${post.id}`);
}

export default function NewPost() {
  const actionData = useActionData<typeof action>();
  
  return (
    <Form method="post">
      <div>
        <label>标题</label>
        <input name="title" />
        {actionData?.errors?.title && (
          <p className="error">{actionData.errors.title}</p>
        )}
      </div>
      <div>
        <label>内容</label>
        <textarea name="content" />
        {actionData?.errors?.content && (
          <p className="error">{actionData.errors.content}</p>
        )}
      </div>
      <button type="submit">发布</button>
    </Form>
  );
}

5.2 使用Zod验证 #

tsx
import { z } from "zod";

const schema = z.object({
  title: z.string().min(3, "标题至少3个字符"),
  content: z.string().min(10, "内容至少10个字符"),
  email: z.string().email("无效的邮箱格式"),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const result = schema.safeParse(Object.fromEntries(formData));
  
  if (!result.success) {
    return json(
      { errors: result.error.flatten().fieldErrors },
      { status: 400 }
    );
  }
  
  const post = await createPost(result.data);
  return redirect(`/posts/${post.id}`);
}

六、多按钮表单 #

6.1 Intent模式 #

tsx
import { Form } from "@remix-run/react";

export default function EditPost({ post }) {
  return (
    <Form method="post">
      <input name="title" defaultValue={post.title} />
      <textarea name="content" defaultValue={post.content} />
      
      <button type="submit" name="intent" value="save">
        保存草稿
      </button>
      <button type="submit" name="intent" value="publish">
        发布
      </button>
      <button type="submit" name="intent" value="preview">
        预览
      </button>
    </Form>
  );
}

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get("intent");
  
  switch (intent) {
    case "save":
      return saveDraft(formData);
    case "publish":
      return publishPost(formData);
    case "preview":
      return previewPost(formData);
    default:
      return json({ error: "未知操作" }, { status: 400 });
  }
}

6.2 删除确认 #

tsx
export default function PostItem({ post }) {
  return (
    <div>
      <h2>{post.title}</h2>
      <Form method="post" action={`/posts/${post.id}`}>
        <input type="hidden" name="intent" value="delete" />
        <button 
          type="submit"
          onClick={(e) => {
            if (!confirm("确定要删除吗?")) {
              e.preventDefault();
            }
          }}
        >
          删除
        </button>
      </Form>
    </div>
  );
}

七、表单重置 #

7.1 成功后重置 #

tsx
import { useRef, useEffect } from "react";
import { Form, useNavigation } from "@remix-run/react";

export default function Contact() {
  const formRef = useRef<HTMLFormElement>(null);
  const navigation = useNavigation();
  
  useEffect(() => {
    if (navigation.state === "idle" && formRef.current) {
      formRef.current.reset();
    }
  }, [navigation.state]);
  
  return (
    <Form ref={formRef} method="post">
      <input name="name" />
      <input name="email" type="email" />
      <button type="submit">发送</button>
    </Form>
  );
}

八、无障碍访问 #

8.1 关联标签 #

tsx
<Form method="post">
  <div>
    <label htmlFor="title">标题</label>
    <input 
      id="title" 
      name="title" 
      aria-describedby="title-error"
      aria-invalid={!!actionData?.errors?.title}
    />
    {actionData?.errors?.title && (
      <p id="title-error" role="alert">
        {actionData.errors.title}
      </p>
    )}
  </div>
  <button type="submit">提交</button>
</Form>

8.2 焦点管理 #

tsx
import { useEffect, useRef } from "react";
import { useActionData } from "@remix-run/react";

export default function Form() {
  const actionData = useActionData<typeof action>();
  const errorRef = useRef<HTMLDivElement>(null);
  
  useEffect(() => {
    if (actionData?.errors && errorRef.current) {
      errorRef.current.focus();
    }
  }, [actionData]);
  
  return (
    <Form method="post">
      {actionData?.errors && (
        <div ref={errorRef} tabIndex={-1} role="alert">
          表单验证失败
        </div>
      )}
      {/* 表单字段 */}
    </Form>
  );
}

九、最佳实践 #

9.1 加载状态 #

tsx
export default function Form() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";
  
  return (
    <Form method="post" className={isSubmitting ? "opacity-50" : ""}>
      <fieldset disabled={isSubmitting}>
        <input name="title" />
        <button type="submit">
          {isSubmitting ? "提交中..." : "提交"}
        </button>
      </fieldset>
    </Form>
  );
}

9.2 保留表单数据 #

tsx
export default function EditPost({ post }) {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  
  // 使用提交中的数据或原始数据
  const title = navigation.formData?.get("title") || post.title;
  const content = navigation.formData?.get("content") || post.content;
  
  return (
    <Form method="post">
      <input name="title" defaultValue={title} />
      <textarea name="content" defaultValue={content} />
      <button type="submit">保存</button>
    </Form>
  );
}

9.3 成功反馈 #

tsx
import { useEffect, useState } from "react";
import { useActionData, useNavigation } from "@remix-run/react";

export default function Form() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const [showSuccess, setShowSuccess] = useState(false);
  
  useEffect(() => {
    if (actionData?.success) {
      setShowSuccess(true);
      const timer = setTimeout(() => setShowSuccess(false), 3000);
      return () => clearTimeout(timer);
    }
  }, [actionData]);
  
  return (
    <div>
      {showSuccess && (
        <div className="success">操作成功!</div>
      )}
      <Form method="post">
        {/* 表单字段 */}
      </Form>
    </div>
  );
}

十、总结 #

本章我们学习了:

  1. Form组件:基本用法和属性配置
  2. 表单状态:useNavigation 获取提交状态
  3. useFetcher:不导航的表单提交
  4. 表单验证:服务端验证和错误显示
  5. 用户体验:加载状态和成功反馈

核心要点:

  • 使用 Form 组件增强原生表单
  • 使用 useFetcher 处理无导航提交
  • 在服务端进行表单验证
  • 提供清晰的加载和错误状态
最后更新:2026-03-28