技能创建器
skill-creator
创建、改进和评估自定义技能,把专业流程转化为可重复使用的 AI 能力。
conform【数据与研究】Conform is a React form validation library that progressively enhances native HTML forms: it validates FormData against a Zod, Valibot or Yup schema on the server and, optionally, on the client, so a form works before JavaScript loads. Use when a user asks to build a form with Conform, validate a Next.js Server Action or a React Router / Remix action…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Conform is a progressive enhancement form library. The form posts plain FormData; the server parses and validates it against a schema and sends the result back; once JavaScript loads, the same schema can validate in the browser. It does not own your markup — any valid HTML form works, and field names, ids and ARIA attributes are derived for you.
Version 1.21 (August 2026) ships two APIs in the same packages. The stable API (@conform-to/react, @conform-to/zod) is what the guides on conform.guide describe and is covered first below. The future API (@conform-to/react/future) is the preview of v2: the official examples already use it, but it is marked experimental and may change in a minor release.
npm install @conform-to/react @conform-to/zod zod
npm install @conform-to/react @conform-to/valibot valibot # Valibot instead of Zod
npm install @conform-to/react @conform-to/yup yup # Yup instead of Zod
The Zod adapter has one entry point per Zod major. npm install zod now gives Zod 4, so import from the v4 subpath; the bare @conform-to/zod import is for Zod 3 and fails when Zod 4 is installed with a missing-export error (The requested module 'zod' does not provide an export named 'ZodBranded' in Node; bundlers word it differently).
import { parseWithZod } from '@conform-to/zod/v4'; // Zod 4
import { parseWithZod } from '@conform-to/zod'; // Zod 3
Keep the schema in its own module: a 'use server' file may only export async functions, so a schema exported from the action file cannot be imported by the client form.
// app/contact/schema.ts — shared by the server action and the client form
import { z } from 'zod';
export const inquirySchema = z.object({
name: z.string({ error: 'Name is required' }).min(2),
email: z.email('Enter a valid email address'),
seats: z.number().int().min(1).max(20),
newsletter: z.boolean().default(false),
message: z.string({ error: 'Message is required' }).min(10).max(1000),
});
// app/contact/actions.ts
'use server';
import { parseWithZod } from '@conform-to/zod/v4';
import { saveInquiry, seatsLeft } from '@/lib/inquiries';
import { inquirySchema } from './schema';
export async function submitInquiry(prevState: unknown, formData: FormData) {
const submission = parseWithZod(formData, { schema: inquirySchema });
if (submission.status !== 'success') {
return submission.reply(); // field errors + submitted values go back to the form
}
if (submission.value.seats > (await seatsLeft())) {
return submission.reply({ fieldErrors: { seats: ['Not enough seats left'] } });
}
await saveInquiry(submission.value); // typed: seats is a number, newsletter a boolean
return submission.reply({ resetForm: true });
}
parseWithZod coerces form strings before validating: an empty string becomes undefined, z.number() and z.bigint() fields are cast, z.boolean() is true when the value is on, z.date() goes through new Date(). Pass disableAutoCoercion: true to do this yourself. reply() also accepts formErrors (errors for the whole form) and hideFields: ['password'] (do not echo a value back to the browser).
// app/contact/form.tsx
'use client';
import { useActionState } from 'react';
import { getFormProps, getInputProps, getTextareaProps, useForm } from '@conform-to/react';
import { getZodConstraint, parseWithZod } from '@conform-to/zod/v4';
import { submitInquiry } from './actions';
import { inquirySchema } from './schema';
export function InquiryForm() {
const [lastResult, action, pending] = useActionState(submitInquiry, undefined);
const [form, fields] = useForm({
id: 'inquiry', // prefix for generated ids; omit it to get a random one
lastResult, // server result: errors and values survive a no-JS round trip
constraint: getZodConstraint(inquirySchema), // required/min/max attributes (Zod 4.6+: see Guidelines)
shouldValidate: 'onBlur',
shouldRevalidate: 'onInput',
onValidate({ formData }) {
return parseWithZod(formData, { schema: inquirySchema });
},
});
return (
<form {...getFormProps(form)} action={action}>
<div id={form.errorId}>{form.errors}</div>
<label htmlFor={fields.name.id}>Name</label>
<input {...getInputProps(fields.name, { type: 'text' })} />
<div id={fields.name.errorId}>{fields.name.errors}</div>
<label htmlFor={fields.email.id}>Email</label>
<input {...getInputProps(fields.email, { type: 'email' })} />
<div id={fields.email.errorId}>{fields.email.errors}</div>
<label htmlFor={fields.seats.id}>Seats</label>
<input {...getInputProps(fields.seats, { type: 'number' })} />
<div id={fields.seats.errorId}>{fields.seats.errors}</div>
<label htmlFor={fields.newsletter.id}>Send me the newsletter</label>
<input {...getInputProps(fields.newsletter, { type: 'checkbox' })} />
<label htmlFor={fields.message.id}>Message</label>
<textarea {...getTextareaProps(fields.message)} />
<div id={fields.message.errorId}>{fields.message.errors}</div>
<button disabled={pending}>Send</button>
</form>
);
}
useForm returns a tuple, [form, fields]. Every option is optional: leave out onValidate and validation runs only on the server, including on blur and on input, which keeps the schema out of the client bundle at the cost of a request per check.shouldValidate takes 'onSubmit' (default), 'onBlur' or 'onInput'; shouldRevalidate takes the same values and defaults to whatever shouldValidate is.getFormProps, getInputProps (the type option is required), getTextareaProps, getSelectProps, getFieldsetProps and getCollectionProps set id, name, form, key, default value, constraint and ARIA attributes. They are optional — the same values are on the metadata (fields.email.name, .initialValue, .errors, .errorId, .valid).useFormState from react-dom instead of useActionState.useActionData() and pass it as lastResult, render <Form method="post" {...getFormProps(form)}>, and call parseWithZod(await request.formData(), { schema }) in the action. If the action resets a form whose defaults come from a loader, pass lastResult: navigation.state === 'idle' ? lastResult : null so the reset waits for revalidation.Field names follow address.city and lines[0].amount. Call fields.address.getFieldset() for an object and fields.lines.getFieldList() for an array instead of writing names by hand. Lists change through intent buttons — ordinary submit buttons carrying a reserved name, so they work without JavaScript: form.insert, form.remove, form.reorder, form.update, form.reset and form.validate, each with .getButtonProps({ name, ... }) or callable directly, e.g. form.validate({ name: fields.email.name }). See Example 2.
Files: set method="POST" and encType="multipart/form-data" on the form and validate with z.instanceof(File). For a multi-file input or a checkbox group, errors attach to each index (files[0]), so render Object.values(fields.files.allErrors).flat().
Custom inputs (a select or date picker from a UI library that renders no native input): const control = useInputControl(fields.color) gives control.value, control.change(value), control.focus() and control.blur() to wire into the component.
// app/signup/schema.ts
import { coerceFormValue } from '@conform-to/zod/v4/future';
import { z } from 'zod';
// coerceFormValue strips empty strings and casts numbers, booleans and dates
export const signupSchema = coerceFormValue(
z.object({ email: z.email(), age: z.number().min(18), terms: z.boolean() }),
);
// app/signup/actions.ts
'use server';
import { parseSubmission, report } from '@conform-to/react/future';
import { isEmailTaken } from '@/lib/accounts';
import { signupSchema } from './schema';
export async function register(prevState: unknown, formData: FormData) {
const submission = parseSubmission(formData);
const result = signupSchema.safeParse(submission.payload);
if (!result.success) {
return report(submission, { error: { issues: result.error.issues } });
}
if (await isEmailTaken(result.data.email)) {
return report(submission, { error: { fieldErrors: { email: ['Email is already registered'] } } });
}
return report(submission, { reset: true });
}
// in the client component
import { useForm } from '@conform-to/react/future';
const [lastResult, action] = useActionState(register, null);
const { form, fields, intent } = useForm(signupSchema, { lastResult, shouldValidate: 'onBlur' });
// <form {...form.props} action={action}>
// <input name={fields.email.name} defaultValue={fields.email.defaultValue}
// aria-invalid={!fields.email.valid || undefined} aria-describedby={fields.email.ariaDescribedBy} />
What differs from the stable API: useForm takes any Standard Schema as its first argument and returns an object, not a tuple; form.props replaces getFormProps; fields expose defaultValue instead of initialValue; parseSubmission + report replace parseWithZod + reply; list changes are method calls (intent.insert({ name }), intent.remove({ name, index })); constraints come from getConstraints(schema). Do not mix the two in one form — a lastResult produced by report() is not the shape the stable useForm expects, and the reverse.
User request: "Add a contact form to our Next.js app with Conform. It has to work with JavaScript disabled."
Create the three files from Instructions (schema.ts, actions.ts, form.tsx) and render <InquiryForm /> on the page. Submitting email=maya@northwind, seats=4, newsletter=on with the other fields empty makes the action return:
{
"status": "error",
"initialValue": { "email": "maya@northwind", "newsletter": "on", "seats": "4" },
"error": { "name": ["Name is required"], "email": ["Enter a valid email address"],
"message": ["Message is required"] },
"fields": ["name", "email", "seats", "newsletter", "message"]
}
The page re-renders with the values kept and the errors wired to the inputs, whether or not JavaScript ran:
<input required id="inquiry-email" form="inquiry" aria-invalid="true"
aria-describedby="inquiry-email-error" type="email" name="email" value="maya@northwind"/>
<div id="inquiry-email-error">Enter a valid email address</div>
A valid submission reaches saveInquiry as { name: 'Maya Okafor', email: 'maya@northwind.io', seats: 4, newsletter: true, message: '…' } — already typed and coerced.
User request: "Build an invoice form with Conform where I can add and remove line items."
'use client';
import { getFormProps, getInputProps, useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod/v4';
import { z } from 'zod';
const invoiceSchema = z.object({
customer: z.string().min(1),
lines: z
.array(z.object({ description: z.string().min(1), amount: z.number().positive() }))
.min(1, 'Add at least one line'),
});
export function InvoiceForm() {
const [form, fields] = useForm({
defaultValue: { customer: 'Halvorsen Marine AS', lines: [{ description: '', amount: '' }] },
shouldValidate: 'onBlur',
onValidate({ formData }) {
return parseWithZod(formData, { schema: invoiceSchema });
},
});
const lines = fields.lines.getFieldList();
return (
<form {...getFormProps(form)} method="post">
<input {...getInputProps(fields.customer, { type: 'text' })} />
{lines.map((line, index) => {
const { description, amount } = line.getFieldset();
return (
<fieldset key={line.key}>
<input {...getInputProps(description, { type: 'text' })} />
<input {...getInputProps(amount, { type: 'number' })} step="0.01" />
<div>{amount.errors}</div>
<button {...form.remove.getButtonProps({ name: fields.lines.name, index })}>Remove</button>
</fieldset>
);
})}
<div>{fields.lines.errors}</div>
<button {...form.insert.getButtonProps({ name: fields.lines.name })}>Add line</button>
<button>Save invoice</button>
</form>
);
}
The browser posts lines[0].description=Hull inspection, lines[0].amount=1450.00, lines[1].description=Antifouling paint, lines[1].amount=389.50. On the server, parseWithZod(formData, { schema: invoiceSchema }).value is:
{ "customer": "Halvorsen Marine AS",
"lines": [{ "description": "Hull inspection", "amount": 1450 },
{ "description": "Antifouling paint", "amount": 389.5 }] }
An amount of -5 in the second row comes back as { "lines[1].amount": ["Too small: expected number to be >0"] }; removing every row gives { "lines": ["Add at least one line"] }.
onValidate is a convenience; always parse the FormData again in the action.noValidate on the form by default, even before hydration, so the browser's own required/minLength bubbles never show. Pass defaultNoValidate: false to useForm if you want native validation until JavaScript takes over. The constraint attributes still help screen readers either way.getZodConstraint and getConstraints return only required when Zod 4.6 or later is installed; minLength, maxLength, min, max and pattern are derived correctly up to Zod 4.5 (issue 1340 in the repository, open in September 2026). Validation is unaffected — only the HTML attributes are missing. Log the constraint object once after a Zod upgrade.conformZodMessage.VALIDATION_UNDEFINED on the client so Conform falls back to the server, and call parseWithZod(formData, { schema, async: true }) in the action.form.reset and form.update re-mount inputs through key. Inputs set up by hand need key={fields.title.key}; the get*Props helpers add it.submission.reply({ hideFields: ['password'] }), or report(submission, { hideFields: [...] }) in the future API.serverActions.bodySizeLimit, a multipart parser with file and part limits) — Conform parses whatever FormData it is given.