Open most React form libraries and you will find an entire state machine reimplemented on top of the DOM: controlled values, ref registries, dirty flags, custom submit handlers, all of it living in JavaScript memory. It works, but it quietly throws away everything the browser already knows about forms. Native HTML forms validate fields, serialize their data, submit themselves, and report errors to assistive technology without a single line of JavaScript. Conform is built on a simple observation: that machinery is good, so why not keep it and add type safety on top?
Conform, authored by Edmund Hung, is a type-safe form validation library that progressively enhances HTML forms with React. Its React adapter ships as @conform-to/react, and the whole approach leans on two web standards — the FormData API for reading values out of the DOM, and the browser's native constraint validation for the baseline behavior. Forms submit and validate before your JavaScript bundle ever arrives, then get richer once React hydrates. That progressive-enhancement-first design pairs naturally with server actions, which is why Conform has first-class support for Remix, React Router, and Next.js. With around 247,000 weekly downloads on the React package and a stable 1.x line, it has become one of the go-to choices for teams building on the server-action model.
Why Build on the Platform Instead of Around It
The central idea is that the form is the source of truth, not a React state object that mirrors it. When the user submits, Conform reads values straight from the DOM as a FormData object and validates that, syncing fine-grained state through event delegation. This has a few pleasant consequences.
Because the form works as plain HTML, it keeps working if JavaScript fails to load, runs slowly, or errors out partway. Validation can happen on the server using the exact same schema you use on the client, so there is no second set of rules to keep in sync. And because Conform wires fields up through the platform's own accessibility hooks, screen-reader support is not an afterthought you bolt on later.
Conform also gives you full type safety. The fields object you get back is inferred from your schema, so fields.email knows it exists and fields.email.errors is typed. Re-renders are fine-grained: only the parts of the form whose state actually changed will update, which keeps large forms responsive without manual memoization gymnastics.
Getting It Into Your Project
Conform splits into small focused packages. You always want the React adapter, plus a schema integration. Zod is the most common choice; Valibot is fully supported too.
# npm — with Zod
npm install @conform-to/react @conform-to/zod zod
# npm — with Valibot
npm install @conform-to/react @conform-to/valibot valibot
# yarn — with Zod
yarn add @conform-to/react @conform-to/zod zod
The @conform-to/react package pulls in @conform-to/dom, the framework-agnostic core, automatically. All the packages are versioned together, so keeping them on matching versions is as simple as it sounds. Conform's peer dependencies are React 18 or newer.
A First Form That Validates Itself
Here is a login form. Notice that there is no useState for the field values, no onChange handlers, and no controlled inputs. You describe the shape with a schema, hand Conform a validation function, and spread the generated props onto your elements.
import { useForm, getFormProps, getInputProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
});
function LoginForm() {
const [form, fields] = useForm({
shouldValidate: 'onBlur',
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<form {...getFormProps(form)}>
<label htmlFor={fields.email.id}>Email</label>
<input {...getInputProps(fields.email, { type: 'email' })} />
{fields.email.errors && <span>{fields.email.errors[0]}</span>}
<label htmlFor={fields.password.id}>Password</label>
<input {...getInputProps(fields.password, { type: 'password' })} />
{fields.password.errors && <span>{fields.password.errors[0]}</span>}
<button type="submit">Log in</button>
</form>
);
}
useForm returns a tuple: form metadata and a fields object. The getInputProps helper does a surprising amount of work — it sets the input's name, id, default value, aria-invalid, and aria-describedby so that errors are correctly associated with the field for assistive technology. The parseWithZod call inside onValidate parses the raw FormData against your schema and also handles type coercion, turning a string from the DOM into the number, date, or boolean your schema expects.
Choosing When Validation Fires
By default Conform validates on submit, which is the least noisy option for users. You tune the timing with two options. shouldValidate controls when a field is first validated, and shouldRevalidate controls when it is checked again afterward.
const [form, fields] = useForm({
shouldValidate: 'onBlur',
shouldRevalidate: 'onInput',
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
This is a common, friendly pattern: validate a field the first time the user leaves it (onBlur), then once it has shown an error, re-check on every keystroke (onInput) so the message clears the moment the input becomes valid. Both options accept 'onSubmit', 'onBlur', or 'onInput', and shouldRevalidate defaults to whatever shouldValidate is.
One Schema, Client and Server
This is where Conform's design pays off. Because validation is just a function that takes FormData and returns a result, you can run the identical schema in a server action. The server validates authoritatively, and the result is fed back into useForm through lastResult so the client picks up exactly where the server left off — including when JavaScript has not loaded yet.
// app/actions.ts (server)
'use server';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function login(prevState: unknown, formData: FormData) {
const submission = parseWithZod(formData, { schema });
if (submission.status !== 'success') {
return submission.reply();
}
// ... authenticate the user ...
return submission.reply();
}
// the client component
import { useActionState } from 'react';
import { useForm, getFormProps, getInputProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { login } from './actions';
function LoginForm() {
const [lastResult, action] = useActionState(login, undefined);
const [form, fields] = useForm({
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<form {...getFormProps(form)} action={action}>
<input {...getInputProps(fields.email, { type: 'email' })} />
{fields.email.errors && <span>{fields.email.errors[0]}</span>}
<input {...getInputProps(fields.password, { type: 'password' })} />
{fields.password.errors && <span>{fields.password.errors[0]}</span>}
<button type="submit">Log in</button>
</form>
);
}
submission.reply() produces a serializable result the client understands. The client-side onValidate becomes a fast-feedback layer that is entirely optional — remove it and the server still validates correctly. That is the progressive enhancement promise made concrete: the form is functional without the client validation, and better with it.
Nested Objects and Dynamic Lists
Real forms are rarely flat. Conform models nested objects and arrays through the same typed fields metadata, and it provides intent buttons for manipulating dynamic lists — inserting, removing, and reordering items — without you wiring up bespoke state.
import { useForm, getFormProps, getInputProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({
title: z.string(),
tasks: z.array(z.object({ name: z.string() })).min(1),
});
function ChecklistForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
const tasks = fields.tasks.getFieldList();
return (
<form {...getFormProps(form)}>
<input {...getInputProps(fields.title, { type: 'text' })} />
{tasks.map((task) => {
const taskFields = task.getFieldset();
return (
<div key={task.key}>
<input {...getInputProps(taskFields.name, { type: 'text' })} />
<button {...form.remove.getButtonProps({ name: fields.tasks.name, index: task.index })}>
Remove
</button>
</div>
);
})}
<button {...form.insert.getButtonProps({ name: fields.tasks.name })}>
Add task
</button>
<button type="submit">Save</button>
</form>
);
}
getFieldList walks into an array field and gives you a typed entry per item, each with its own getFieldset for the nested object. The intent buttons created by form.insert and form.remove are real submit buttons under the hood, which means list manipulation degrades gracefully too — it can round-trip through the server when needed rather than depending entirely on client state.
How It Stacks Up
The obvious comparison is React Hook Form, which dominates by download count and centers on a ref-based, mostly-uncontrolled client model with an enormous ecosystem. Formik is the older controlled-state option, heavier on re-renders and less actively maintained. TanStack Form is a newer headless, framework-agnostic library with similarly strong type-safety goals. Conform's distinguishing bet is different from all of them: it is FormData-first and server-action-native, treating progressive enhancement and accessibility as the default rather than features to opt into.
That focus is also the honest trade-off. Conform's mental model — "the DOM and FormData are the truth" — is genuinely different if you arrive expecting a classic controlled-component form library, and there is a short learning curve. The community is smaller than React Hook Form's, and the library shines brightest when you have a server or action layer to validate against. For a pure client-only SPA with no backend, you can still use it, but you are leaving some of its best ideas on the table.
Closing Thoughts
Conform is what you get when a form library decides to stop fighting the browser. By building on the FormData API and native constraint validation, it produces forms that are resilient, accessible, and type-safe almost as a side effect of doing things the standards-based way. Reuse one Zod or Valibot schema across client and server, lean on the platform for the heavy lifting, and let React enhance rather than replace it. If your stack already speaks server actions — Remix, React Router, or Next.js — @conform-to/react is one of the most natural-fitting form libraries you can reach for, and the kind of tool that, much like a contented cat on a warm stack of envelopes, quietly does exactly what it should without making a fuss about it.