Loading...
Loading...
Compare original and translation side by side
npm install @conform-to/react @conform-to/zod zodnpm 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>
);
}// 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";
// 定义Schema
const loginSchema = z.object({
email: z.string().email("无效的邮箱地址"),
password: z.string().min(8, "密码长度至少为8位"),
remember: z.boolean().optional(),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// 使用Zod解析
const submission = parseWithZod(formData, { schema: loginSchema });
// 验证失败时返回错误
if (submission.status !== "success") {
return json({ submission: submission.reply() });
}
// 处理有效数据
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}>邮箱</label>
<input
{...getInputProps(fields.email, { type: "email" })}
key={fields.email.key}
/>
<div>{fields.email.errors}</div>
</div>
<div>
<label htmlFor={fields.password.id}>密码</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}
/>
记住我
</label>
</div>
<button type="submit">登录</button>
</Form>
);
}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: "user@example.com",
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 [form, fields] = useForm({
// 表单ID(嵌套表单必填)
id: "login-form",
// 来自action的上次提交结果
lastResult: actionData?.submission,
// 默认值
defaultValue: {
email: "user@example.com",
remember: true,
},
// 客户端验证
onValidate: ({ formData }) => {
return parseWithZod(formData, { schema: loginSchema });
},
// 验证触发时机
shouldValidate: "onBlur", // 可选值:"onInput" | "onSubmit"
shouldRevalidate: "onInput", // 首次验证后触发
// 回调函数
onSubmit: (event, context) => {
// 可选:自定义提交处理
// 如需阻止默认提交,调用event.preventDefault()
},
// 约束验证(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({
name: z.string().min(1, "姓名为必填项"),
bio: z.string().max(500, "个人简介过长").optional(),
});
// 组件中使用
<div>
<label htmlFor={fields.name.id}>姓名</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}>个人简介</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({
age: z.number().min(18, "必须年满18周岁"),
price: z.number().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({
role: z.enum(["STUDENT", "TEACHER", "ADMIN"]),
country: z.string().min(1, "国家为必填项"),
});
<select
{...getSelectProps(fields.role)}
key={fields.role.key}
>
<option value="">选择角色</option>
<option value="STUDENT">学生</option>
<option value="TEACHER">教师</option>
<option value="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({
terms: z.literal(true, {
errorMap: () => ({ message: "您必须接受条款" }),
}),
newsletter: z.boolean().optional(),
});
<label>
<input
{...getInputProps(fields.terms, { type: "checkbox" })}
key={fields.terms.key}
/>
我接受条款和条件
</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({
plan: z.enum(["free", "pro", "enterprise"]),
});
<fieldset>
<legend>选择套餐</legend>
<label>
<input
{...getInputProps(fields.plan, { type: "radio" })}
value="free"
/>
免费版
</label>
<label>
<input
{...getInputProps(fields.plan, { type: "radio" })}
value="pro"
/>
专业版
</label>
<label>
<input
{...getInputProps(fields.plan, { type: "radio" })}
value="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 schema = z.object({
birthDate: z.string().refine(
(date) => {
const parsed = new Date(date);
return !isNaN(parsed.getTime());
},
{ message: "无效日期" }
),
scheduledDate: z.string().refine(
(date) => new Date(date) > new Date(),
{ message: "日期必须为未来时间" }
),
});
<input
{...getInputProps(fields.birthDate, { type: "date" })}
key={fields.birthDate.key}
max={new Date().toISOString().split("T")[0]} // 今日或更早
/>
<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 MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const schema = z.object({
avatar: z
.instanceof(File)
.refine(
(file) => file.size <= MAX_FILE_SIZE,
"文件大小不得超过5MB"
)
.refine(
(file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
"仅支持JPEG、PNG和WebP格式图片"
),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, {
schema: schema.transform(async (data) => {
// 上传至S3
const url = await uploadToS3(data.avatar);
return { avatarUrl: url };
}),
async: true, // 异步转换必填
});
if (submission.status !== "success") {
return json({ submission: submission.reply() });
}
// 使用submission.value.avatarUrl
await updateUser(userId, { avatarUrl: submission.value.avatarUrl });
return redirect("/profile");
}
// 组件中使用
<div>
<label htmlFor={fields.avatar.id}>头像</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({
photos: z
.array(z.instanceof(File))
.min(1, "至少需要上传一张照片")
.max(5, "最多上传5张照片")
.refine(
(files) => files.every((file) => file.size <= MAX_FILE_SIZE),
"每个文件大小不得超过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>
);
}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}$/, "无效的邮政编码"),
}),
});
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>地址</legend>
<input
{...getInputProps(address.street, { type: "text" })}
placeholder="街道"
/>
<div>{address.street.errors}</div>
<input
{...getInputProps(address.city, { type: "text" })}
placeholder="城市"
/>
<div>{address.city.errors}</div>
<input
{...getInputProps(address.zipCode, { type: "text" })}
placeholder="邮政编码"
/>
<div>{address.zipCode.errors}</div>
</fieldset>
<button type="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>
);
}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, "至少需要填写一个邮箱地址"),
});
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>邮箱地址</legend>
{emails.map((email, index) => {
const emailFields = email.getFieldset();
return (
<div key={email.key}>
<input
{...getInputProps(emailFields.address, { type: "email" })}
placeholder="邮箱地址"
/>
<div>{emailFields.address.errors}</div>
<label>
<input
{...getInputProps(emailFields.primary, { type: "checkbox" })}
/>
主邮箱
</label>
<button
{...form.remove.getButtonProps({
name: fields.emails.name,
index,
})}
>
删除
</button>
</div>
);
})}
<button
{...form.insert.getButtonProps({
name: fields.emails.name,
})}
>
添加邮箱
</button>
</fieldset>
<button type="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 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") {
// 验证步骤1
const submission = parseWithZod(formData, { schema: step1Schema });
if (submission.status !== "success") {
return json({ submission: submission.reply(), step: 1 });
}
// 跳转至步骤2
return json({ submission: submission.reply(), step: 2 });
}
// 最终提交 - 验证所有字段
const submission = parseWithZod(formData, { schema: fullSchema });
if (submission.status !== "success") {
return json({ submission: submission.reply(), step: 2 });
}
// 创建账号
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>步骤1:账号信息</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)}>
下一步
</button>
</>
)}
{currentStep === 2 && (
<>
<h2>步骤2:个人资料</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)}>
返回
</button>
<button type="submit">创建账号</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({
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: "收货地址为必填项",
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" })}
/>
使用不同的收货地址
</label>
{hasShipping && (
<fieldset>
<input {...getInputProps(shippingFields.street, { type: "text" })} />
<input {...getInputProps(shippingFields.city, { type: "text" })} />
</fieldset>
)}
<button type="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 schema = z.object({
email: z.string().email("请输入有效的邮箱地址"),
password: z
.string()
.min(8, "密码长度至少为8位")
.regex(/[A-Z]/, "密码必须包含大写字母")
.regex(/[a-z]/, "密码必须包含小写字母")
.regex(/[0-9]/, "密码必须包含数字"),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "两次输入的密码不一致",
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>;
}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: "该邮箱已注册" }
),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// 服务端验证(包含异步检查)
const submission = await parseWithZod(formData, {
schema: serverSchema,
async: true,
});
if (submission.status !== "success") {
return json({ submission: submission.reply() });
}
// 创建用户
await createUser(submission.value);
return redirect("/login");
}
export default function Signup() {
const actionData = useActionData<typeof action>();
const [form, fields] = useForm({
lastResult: actionData?.submission,
// 客户端验证(无异步检查)
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>
);
}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>✓ 可用</span>}
{availability === "taken" && <span>✗ 已被占用</span>}
<div>{field.errors}</div>
</div>
);
}// 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>// 生成的HTML包含:
<form id="login-form" aria-invalid={form.errors ? true : undefined}>
<label htmlFor="email-input">邮箱</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><div>
<label htmlFor={fields.email.id}>
邮箱
<span aria-label="必填">*</span>
</label>
<input
{...getInputProps(fields.email, { type: "email" })}
aria-required="true"
aria-describedby={`${fields.email.errorId} email-hint`}
/>
<div id="email-hint">我们绝不会分享您的邮箱</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", "user@example.com");
formData.set("password", "password123");
const submission = parseWithZod(formData, { schema: loginSchema });
expect(submission.status).toBe("success");
expect(submission.value).toEqual({
email: "user@example.com",
password: "password123",
});
});
});import { parseWithZod } from "@conform-to/zod";
import { describe, test, expect } from "vitest";
describe("登录表单", () => {
test("验证邮箱格式", () => {
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("接受有效数据", () => {
const formData = new FormData();
formData.set("email", "user@example.com");
formData.set("password", "password123");
const submission = parseWithZod(formData, { schema: loginSchema });
expect(submission.status).toBe("success");
expect(submission.value).toEqual({
email: "user@example.com",
password: "password123",
});
});
});