关于 conform
Conform 是一份技能指南,介绍如何在 Remix 中使用 Zod 验证构建可访问、渐进增强的表单。它面向实现表单的开发者,涵盖类型安全验证、文件上传和多步骤表单等模式。
主要功能
- 01支持无 JavaScript 渐进增强
- 02基于 Zod 的类型安全验证
- 03生成无障碍表单属性
- 04文件上传与动态字段数组
- 05多步骤及嵌套表单
适用场景
- 01在 Remix 中构建经验证的登录表单
- 02创建包含动态字段列表的表单
- 03实现多步骤数据录入
- 04验证文件上传
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ tejovanthn/ rasikalife --skill conform安装后在 Claude Code 中输入/conform确认已加载。
详细介绍
Conform 表单
本技能介绍 Conform v1+ 的模式,帮助你在 Remix 中使用 Zod 验证构建无障碍且支持渐进增强的表单。
核心概念
Conform 的优势:
- 渐进增强(无需 JS 也能使用)
- 与 Zod 集成,提供类型安全
- 默认支持无障碍功能(ARIA 属性)
- 受控和非受控模式
- 文件上传
- 多步骤表单
- 字段数组(动态列表)
关键术语:
- 表单:顶层表单配置
- 字段:单个表单输入项
- 约束:HTML5 验证属性
- 提交:表单提交结果
- 字段数组:动态字段列表
安装
npm install @conform-to/react @conform-to/zod zod
基本表单
带验证的简单表单
// app/routes/login.tsx
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { Form, useActionData } from "@remix-run/react";
import { json, redirect } from "@remix-run/node";
import { z } from "zod";
// Define schema
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
remember: z.boolean().optional(),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// Parse with Zod
const submission = parseWithZod(formData, { schema: loginSchema });
// Return errors if validation fails
if (submission.status !== "success") {
return json({ submission: submission.reply() });
}
// Process valid data
const { email, password, remember } = submission.value;
await login(email, password, remember);
return redirect("/dashboard");
}
export default function Login() {
const lastResult = useActionData<typeof action>();
const [form, fields] = useForm({
lastResult: lastResult?.submission,
onValidate: ({ formData }) => {
return parseWithZod(formData, { schema: loginSchema });
},
shouldValidate: "onBlur",
shouldRevalidate: "onInput",
});
return (
<Form method="post" {...getFormProps(form)}>
<div>
<label htmlFor={fields.email.id}>Email</label>
<input
{...getInputProps(fields.email, { type: "email" })}
key={fields.email.key}
/>
<div>{fields.email.errors}</div>
</div>
<div>
<label htmlFor={fields.password.id}>Password</label>
<input
{...getInputProps(fields.password, { type: "password" })}
key={fields.password.key}
/>
<div>{fields.password.errors}</div>
</div>
<div>
<label>
<input
{...getInputProps(fields.remember, { type: "checkbox" })}
key={fields.remember.key}
/>
Remember me
</label>
</div>
<button type="submit">Login</button>
</Form>
);
}
表单配置
useForm Hook 选项
const [form, fields] = useForm({
// ID for the form (required for nested forms)
id: "login-form",
// Last submission result from action
lastResult: actionData?.submission,
// Default values
defaultValue: {
email: "[email protected]",
remember: true,
},
// Client-side validation
onValidate: ({ formData }) => {
return parseWithZod(formData, { schema: loginSchema });
},
// When to validate
shouldValidate: "onBlur", // or "onInput" | "onSubmit"
shouldRevalidate: "onInput", // after first validation
// Callbacks
onSubmit: (event, context) => {
// Optional: custom submit handling
// event.preventDefault() if you want to prevent submission
},
// Constraint validation (HTML5)
constraint: getZodConstraint(loginSchema),
});
字段类型
文本输入
const schema = z.object({
name: z.string().min(1, "Name is required"),
bio: z.string().max(500, "Bio is too long").optional(),
});
// In component
<div>
<label htmlFor={fields.name.id}>Name</label>
<input
{...getInputProps(fields.name, { type: "text" })}
key={fields.name.key}
/>
<div id={fields.name.errorId}>{fields.name.errors}</div>
</div>
<div>
<label htmlFor={fields.bio.id}>Bio</label>
<textarea
{...getTextareaProps(fields.bio)}
key={fields.bio.key}
rows={4}
/>
<div>{fields.bio.errors}</div>
</div>
数字输入
const schema = z.object({
age: z.number().min(18, "Must be 18 or older"),
price: z.number().positive("Price must be positive"),
});
<input
{...getInputProps(fields.age, { type: "number" })}
key={fields.age.key}
min={18}
max={120}
/>
选择框/下拉菜单
const schema = z.object({
role: z.enum(["STUDENT", "TEACHER", "ADMIN"]),
country: z.string().min(1, "Country is required"),
});
<select
{...getSelectProps(fields.role)}
key={fields.role.key}
>
<option value="">Select role</option>
<option value="STUDENT">Student</option>
<option value="TEACHER">Teacher</option>
<option value="ADMIN">Admin</option>
</select>
<div>{fields.role.errors}</div>
复选框
const schema = z.object({
terms: z.literal(true, {
errorMap: () => ({ message: "You must accept terms" }),
}),
newsletter: z.boolean().optional(),
});
<label>
<input
{...getInputProps(fields.terms, { type: "checkbox" })}
key={fields.terms.key}
/>
I accept the terms and conditions
</label>
<div>{fields.terms.errors}</div>
单选按钮
const schema = z.object({
plan: z.enum(["free", "pro", "enterprise"]),
});
<fieldset>
<legend>Choose a plan</legend>
<label>
<input
{...getInputProps(fields.plan, { type: "radio" })}
value="free"
/>
Free
</label>
<label>
<input
{...getInputProps(fields.plan, { type: "radio" })}
value="pro"
/>
Pro
</label>
<label>
<input
{...getInputProps(fields.plan, { type: "radio" })}
value="enterprise"
/>
Enterprise
</label>
<div>{fields.plan.errors}</div>
</fieldset>
日期输入
const schema = z.object({
birthDate: z.string().refine(
(date) => {
const parsed = new Date(date);
return !isNaN(parsed.getTime());
},
{ message: "Invalid date" }
),
scheduledDate: z.string().refine(
(date) => new Date(date) > new Date(),
{ message: "Date must be in the future" }
),
});
<input
{...getInputProps(fields.birthDate, { type: "date" })}
key={fields.birthDate.key}
max={new Date().toISOString().split("T")[0]} // Today or earlier
/>
<input
{...getInputProps(fields.scheduledDate, { type: "datetime-local" })}
key={fields.scheduledDate.key}
/>
文件上传
单文件上传
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const schema = z.object({
avatar: z
.instanceof(File)
.refine(
(file) => file.size <= MAX_FILE_SIZE,
"File size must be less than 5MB"
)
.refine(
(file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
"Only JPEG, PNG, and WebP images are allowed"
),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, {
schema: schema.transform(async (data) => {
// Upload to S3
const url = await uploadToS3(data.avatar);
return { avatarUrl: url };
}),
async: true, // Required for async transforms
});
if (submission.status !== "success") {
return json({ submission: submission.reply() });
}
// Use submission.value.avatarUrl
await updateUser(userId, { avatarUrl: submission.value.avatarUrl });
return redirect("/profile");
}
// In component
<div>
<label htmlFor={fields.avatar.id}>Avatar</label>
<input
{...getInputProps(fields.avatar, { type: "file" })}
key={fields.avatar.key}
accept="image/jpeg,image/png,image/webp"
/>
<div>{fields.avatar.errors}</div>
</div>
多文件上传
const schema = z.object({
photos: z
.array(z.instanceof(File))
.min(1, "At least one photo is required")
.max(5, "Maximum 5 photos allowed")
.refine(
(files) => files.every((file) => file.size <= MAX_FILE_SIZE),
"Each file must be less than 5MB"
),
});
<input
{...getInputProps(fields.photos, { type: "file" })}
key={fields.photos.key}
accept="image/*"
multiple
/>
嵌套对象
const schema = z.object({
name: z.string().min(1),
address: z.object({
street: z.string().min(1),
city: z.string().min(1),
zipCode: z.string().regex(/^\d{5}$/, "Invalid ZIP code"),
}),
});
export default function AddressForm() {
const [form, fields] = useForm({
onValidate: ({ formData }) => parseWithZod(formData, { schema }),
});
const address = fields.address.getFieldset();
return (
<Form method="post" {...getFormProps(form)}>
<input {...getInputProps(fields.name, { type: "text" })} />
<fieldset>
<legend>Address</legend>
<input
{...getInputProps(address.street, { type: "text" })}
placeholder="Street"
/>
<div>{address.street.errors}</div>
<input
{...getInputProps(address.city, { type: "text" })}
placeholder="City"
/>
<div>{address.city.errors}</div>
<input
{...getInputProps(address.zipCode, { type: "text" })}
placeholder="ZIP Code"
/>
<div>{address.zipCode.errors}</div>
</fieldset>
<button type="submit">Submit</button>
</Form>
);
}
字段数组(动态列表)
import { useFieldList } from "@conform-to/react";
const schema = z.object({
name: z.string().min(1),
emails: z
.array(
z.object({
address: z.string().email(),
primary: z.boolean().optional(),
})
)
.min(1, "At least one email is required"),
});
export default function EmailsForm() {
const [form, fields] = useForm({
onValidate: ({ formData }) => parseWithZod(formData, { schema }),
defaultValue: {
emails: [{ address: "", primary: true }],
},
});
const emails = useFieldList(form.ref, fields.emails);
return (
<Form method="post" {...getFormProps(form)}>
<input {...getInputProps(fields.name, { type: "text" })} />
<fieldset>
<legend>Email Addresses</legend>
{emails.map((email, index) => {
const emailFields = email.getFieldset();
return (
<div key={email.key}>
<input
{...getInputProps(emailFields.address, { type: "email" })}
placeholder="Email address"
/>
<div>{emailFields.address.errors}</div>
<label>
<input
{...getInputProps(emailFields.primary, { type: "checkbox" })}
/>
Primary
</label>
<button
{...form.remove.getButtonProps({
name: fields.emails.name,
index,
})}
>
Remove
</button>
</div>
);
})}
<button
{...form.insert.getButtonProps({
name: fields.emails.name,
})}
>
Add Email
</button>
</fieldset>
<button type="submit">Submit</button>
</Form>
);
}
多步骤表单
const step1Schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const step2Schema = z.object({
name: z.string().min(1),
bio: z.string().optional(),
});
const fullSchema = step1Schema.merge(step2Schema);
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const step = formData.get("_step");
if (step === "1") {
// Validate step 1
const submission = parseWithZod(formData, { schema: step1Schema });
if (submission.status !== "success") {
return json({ submission: submission.reply(), step: 1 });
}
// Move to step 2
return json({ submission: submission.reply(), step: 2 });
}
// Final submission - validate everything
const submission = parseWithZod(formData, { schema: fullSchema });
if (submission.status !== "success") {
return json({ submission: submission.reply(), step: 2 });
}
// Create account
await createAccount(submission.value);
return redirect("/dashboard");
}
export default function Signup() {
const actionData = useActionData<typeof action>();
const [currentStep, setCurrentStep] = useState(actionData?.step ?? 1);
const [form, fields] = useForm({
lastResult: actionData?.submission,
onValidate: ({ formData }) => {
const schema = currentStep === 1 ? step1Schema : fullSchema;
return parseWithZod(formData, { schema });
},
});
return (
<Form method="post" {...getFormProps(form)}>
<input type="hidden" name="_step" value={currentStep} />
{currentStep === 1 && (
<>
<h2>Step 1: Account Details</h2>
<input {...getInputProps(fields.email, { type: "email" })} />
<div>{fields.email.errors}</div>
<input {...getInputProps(fields.password, { type: "password" })} />
<div>{fields.password.errors}</div>
<button type="button" onClick={() => setCurrentStep(2)}>
Next
</button>
</>
)}
{currentStep === 2 && (
<>
<h2>Step 2: Profile</h2>
<input {...getInputProps(fields.name, { type: "text" })} />
<div>{fields.name.errors}</div>
<textarea {...getTextareaProps(fields.bio)} />
<div>{fields.bio.errors}</div>
<button type="button" onClick={() => setCurrentStep(1)}>
Back
</button>
<button type="submit">Create Account</button>
</>
)}
</Form>
);
}
高级模式
依赖字段
const schema = z
.object({
hasShippingAddress: z.boolean(),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
}).optional(),
})
.refine(
(data) => {
if (data.hasShippingAddress) {
return data.shippingAddress?.street && data.shippingAddress?.city;
}
return true;
},
{
message: "Shipping address is required",
path: ["shippingAddress"],
}
);
export default function ShippingForm() {
const [form, fields] = useForm({
onValidate: ({ formData }) => parseWithZod(formData, { schema }),
});
const hasShipping = fields.hasShippingAddress.value === "on";
const shippingFields = fields.shippingAddress.getFieldset();
return (
<Form method="post" {...getFormProps(form)}>
<label>
<input
{...getInputProps(fields.hasShippingAddress, { type: "checkbox" })}
/>
Use different shipping address
</label>
{hasShipping && (
<fieldset>
<input {...getInputProps(shippingFields.street, { type: "text" })} />
<input {...getInputProps(shippingFields.city, { type: "text" })} />
</fieldset>
)}
<button type="submit">Submit</button>
</Form>
);
}
自定义验证消息
const schema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain an uppercase letter")
.regex(/[a-z]/, "Password must contain a lowercase letter")
.regex(/[0-9]/, "Password must contain a number"),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ["confirmPassword"],
}
);
仅服务端验证
const clientSchema = z.object({
email: z.string().email(),
username: z.string().min(3),
});
const serverSchema = clientSchema.extend({
email: z.string().email().refine(
async (email) => {
const existing = await db.user.findUnique({ where: { email } });
return !existing;
},
{ message: "Email already registered" }
),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// Server-side validation with async checks
const submission = await parseWithZod(formData, {
schema: serverSchema,
async: true,
});
if (submission.status !== "success") {
return json({ submission: submission.reply() });
}
// Create user
await createUser(submission.value);
return redirect("/login");
}
export default function Signup() {
const actionData = useActionData<typeof action>();
const [form, fields] = useForm({
lastResult: actionData?.submission,
// Client-side validation (no async checks)
onValidate: ({ formData }) => parseWithZod(formData, { schema: clientSchema }),
});
return <Form method="post" {...getFormProps(form)}>...</Form>;
}
字段级意图(失焦/更改事件)
import { useInputControl } from "@conform-to/react";
export default function UsernameField({ field }) {
const control = useInputControl(field);
const [availability, setAvailability] = useState<"available" | "taken" | null>(null);
const checkAvailability = async (username: string) => {
const response = await fetch(`/api/check-username?username=${username}`);
const data = await response.json();
setAvailability(data.available ? "available" : "taken");
};
return (
<div>
<input
{...getInputProps(field, { type: "text" })}
onBlur={() => {
if (control.value) {
checkAvailability(control.value);
}
}}
/>
{availability === "available" && <span>✓ Available</span>}
{availability === "taken" && <span>✗ Already taken</span>}
<div>{field.errors}</div>
</div>
);
}
无障碍功能
Conform 会自动生成无障碍表单:
// Generated HTML includes:
<form id="login-form" aria-invalid={form.errors ? true : undefined}>
<label htmlFor="email-input">Email</label>
<input
id="email-input"
name="email"
type="email"
aria-invalid={fields.email.errors ? true : undefined}
aria-describedby={fields.email.errors ? "email-error" : undefined}
/>
<div id="email-error" aria-live="polite">
{fields.email.errors}
</div>
</form>
手动实现无障碍功能
<div>
<label htmlFor={fields.email.id}>
Email
<span aria-label="required">*</span>
</label>
<input
{...getInputProps(fields.email, { type: "email" })}
aria-required="true"
aria-describedby={`${fields.email.errorId} email-hint`}
/>
<div id="email-hint">We'll never share your email</div>
<div id={fields.email.errorId} aria-live="polite">
{fields.email.errors}
</div>
</div>
测试
import { parseWithZod } from "@conform-to/zod";
import { describe, test, expect } from "vitest";
describe("Login form", () => {
test("validates email format", () => {
const formData = new FormData();
formData.set("email", "invalid");
formData.set("password", "password123");
const submission = parseWithZod(formData, { schema: loginSchema });
expect(submission.status).toBe("error");
expect(submission.reply().error?.email).toBeDefined();
});
test("accepts valid data", () => {
const formData = new FormData();
formData.set("email", "[email protected]");
formData.set("password", "password123");
const submission = parseWithZod(formData, { schema: loginSchema });
expect(submission.status).toBe("success");
expect(submission.value).toEqual({
email: "[email protected]",
password: "password123",
});
});
});
最佳实践
- 始终使用 key 属性:避免 React 状态问题
- 使用 getInputProps 辅助函数:确保属性设置正确
- 先在失焦时验证:相比每次击键都验证,用户体验更好
- 返回 submission.reply():出错时保留表单状态
- 使用 Zod 约束:启用 HTML5 验证
- 正确处理文件上传:使用异步转换
- 测试验证逻辑:为你的 schema 编写单元测试
- 优先考虑无障碍功能:在需要时使用语义化 HTML 和 ARIA
- 渐进增强:无需 JavaScript 也能使用表单
- 类型安全:让 TypeScript 根据 schema 推断类型
常见问题
- 缺少 key 属性:会导致水合问题
- 未使用 reply():验证出错时会丢失表单状态
- 在客户端进行异步验证:请使用仅服务端 schema
- 大文件上传:上传超过 4.5MB 的文件需要流式处理
- 字段数组重新渲染:使用正确的键
- 嵌套对象语法:使用 getFieldset() 处理嵌套对象
- 文件验证:必须使用 instanceof File,而不是 z.string()
- 多步骤验证:验证当前步骤,而不是完整 schema
- 默认值:在 useForm 中使用 defaultValue,而不是在 schema 中设置
- 重置表单:使用 form.reset() 或导航以清除状态
资源
评论
支持 Markdown