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>
);
}
十、总结 #
本章我们学习了:
- Form组件:基本用法和属性配置
- 表单状态:useNavigation 获取提交状态
- useFetcher:不导航的表单提交
- 表单验证:服务端验证和错误显示
- 用户体验:加载状态和成功反馈
核心要点:
- 使用 Form 组件增强原生表单
- 使用 useFetcher 处理无导航提交
- 在服务端进行表单验证
- 提供清晰的加载和错误状态
最后更新:2026-03-28