TL;DR
This article demonstrates how to handle forms in React using Conform and Zod libraries. You’ll learn how to:
- Create type-safe forms with automatic validation
- Support multiple form actions (create, update, delete) with a single handler
- Build accessible forms with proper error handling
- Reduce boilerplate code
Check out the complete working example via CodeSandbox:
Introduction
In this article I’m beginning my blog and series of articles about the yet another way to handle forms in React applications. Keeping struggle in search of convenient way to handle them, I’ve found the powerful duo of Conform and Zod libraries.
I know how to gain huge popularity with my esteemed audience — I just need to create yet another TodoApp to dive into all this fun!
In this example, we will be using React Router.
Why this is important?
In modern development, React is almost always used with frameworks like Next.js or React Router v7 (formerly Remix). In this paradigm, form handling is often moved to the server side, passing user input through formData
.
Let’s first look at a basic implementation of a todo editing form as if we weren’t using any libraries. I’m sure hardly anyone does it this way, but we still need to understand exactly what problems we are solving:
export async function loader() {
const todo = {id: '1', title: "Hold my beer"}
return { todo };
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// 🧐 We can't be sure that all of the fields were passed.
const id = formData.get("id") as string;
const title = formData.get("title") as string;
const errors: Record<string, string | undefined> = {};
// 🤔 Need to add some custom validation logic here
try {
// update todo...
} catch (error) {
return {
errors: {
form: error instanceof Error ? error.message : "Unknown error"
}
} // 😪
}
return { success: true }
}
export default function TodoForm() {
const { todo } = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();
return (
<fetcher.Form
method="post"
{/* Do not forget to handle errors */}
aria-invalid={!!fetcher.data?.errors.form}
aria-describedby={fetcher.data?.errors.form ? "form-error" : undefined}
onSubmit={(e) => {
// 🤔 Need to add some custom validation logic here
// Maybe add another useState to keep track of the errors...
if (!isValid) {
e.preventDefault();
}
})
>
{/* Need to keep an eye on the hardcoded id's,
ensure their uniqueness and avoid typos when linking */}
<div className="text-red-500 text-xs py-1" id="form-error">
{fetcher.data?.errors.form}
</div>
<input type="hidden" name="id" value={todo.id} />
<input
type="text"
name="title"
defaultValue={todo.title}
aria-invalid={!!fetcher.data?.errors?.title} // 😪
aria-describedby={fetcher.data?.errors?.title ? "title-error" : undefined} // 😪
/>
{fetcher.data?.errors && (
<div className="text-red-500 text-xs py-1" id="title-error"> // 😪
{fetcher.data.errors.title}
</div>
)}
<button type="submit">Update</button>
</fetcher.Form>
);
}
// Ahh.. We're also need to create another forms for create and delete.
// Lets do some copy-paste...
So, even in this extremely simple example, we can already see a number of issues that we will inevitably encounter:
- Lack of type safety - TypeScript doesn’t know what we expect from
formData
- A lot of boilerplate code that needs to be written for each field. It’s easy to get lost and make mistakes or typos.
- If the number of fields increases, or if the fields depend on each other, the complexity of the code increases exponentially.
- We need to write validation twice - for the server and the client separately. Error handling is not standardized.
- We need to manually maintain accessibility.
- We don’t want to live that way.
Conform and Zod to the rescue
Let’s start by installing the necessary dependencies:
pnpm add @conform-to/react @conform-to/zod zod
Also, I will be using shadcn/ui components.
pnpm dlx shadcn@latest init
pnpm dlx shadcn@latest add button input checkbox
Define the Zod schema
Now we can define our Zod schema for validation. This will allow us to ensure type safety and automatic validation:
import { z } from "zod";
export const zTodo = z.object({
id: z.number({
required_error: "Id is required",
}),
title: z
.string({
required_error: "Title is required",
})
.min(3, {
message: "Title must be at least 3 characters long",
}),
completed: z.coerce.boolean({
required_error: "Completed is required",
}),
});
export type Todo = z.infer<typeof zTodo>;
Create a Todo creation form and display the list of tasks
Now, let’s create a simple form for adding tasks and a list to display existing ones:
import { useLoaderData, Form, type ActionFunctionArgs } from "react-router";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { fakeApi, type Todo } from "../fakedb";
export async function loader() {
const todos = await fakeApi.getTodos();
return { todos };
}
export default function Todos() {
const { todos } = useLoaderData<typeof loader>();
return (
<div className="mx-auto flex w-md flex-col space-y-4 p-6">
<Form method="post" className="flex space-x-2">
<div className="flex-1">
<Input name="title" placeholder="Add a new todo" autoFocus />
</div>
<div>
<Button type="submit">Add Todo</Button>
</div>
</Form>
<div className="flex-1">
<ul>
{todos.map((todo) => (
<li key={todo.id} className="rounded-md py-2">
{todo.title} {todo.completed ? "(completed)" : ""}
</li>
))}
</ul>
</div>
</div>
);
}

Now, let’s add an action to handle the creation of a task using Conform and Zod:
import { useFetcher, type ActionFunctionArgs } from "react-router";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
// We're only interested in the title field here
const createTodoSchema = zTodo.pick({ title: true });
export async function loader() {
const todos = await fakeApi.getTodos();
return { todos };
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// Ok, here we have the submission object with the parsed data
const submission = parseWithZod(formData, {
schema: createTodoSchema,
});
// If the data is invalid, we return the submission object with the errors
// That's it for validation logic on the server side 🤩
if (submission.status !== "success") {
return submission.reply();
}
try {
await fakeApi.addTodo(submission.value);
} catch (error) {
// Here how we can handle the error from the server
return submission.reply({
formErrors: [error instanceof Error ? error.message : "Unknown error"],
});
}
// If all is good, we're also ask Conform to reset the form
return submission.reply({ resetForm: true });
}
function CreateTodoForm() {
const fetcher = useFetcher<typeof action>();
// Here we're using the Conform hooks to create a form
const [form, fields] = useForm({
lastResult: fetcher.data,
constraint: getZodConstraint(createTodoSchema),
onValidate({ formData }) {
// That's it for validation logic on the client side 🤩
return parseWithZod(formData, { schema: createTodoSchema });
},
});
return (
<fetcher.Form
method="post"
{...getFormProps(form)}
className="flex space-x-2"
>
<div className="flex-1">
<Input
autoFocus
{...getInputProps(fields.title, { type: "text" })}
key={fields.title.key}
placeholder="Add a new todo"
/>
</div>
<div>
<Button type="submit" disabled={fetcher.state !== "idle"}>
Add Todo
</Button>
</div>
</fetcher.Form>
);
}
export default function Todos() {
const { todos } = useLoaderData<typeof loader>();
return (
<div className="mx-auto flex w-md flex-col space-y-4 p-6">
<CreateTodoForm />
{/* rest of the code... */}
</div>
);
}
So, we’ve done several important things:
- We connected the form and handler with typed form data using a Zod schema,
- Used Conform for standardized validation and server responses,
- Automatically configured the form and input components via
getFormProps
andgetInputProps
.
Note:
shadcn/ui
Input is used here, but this will also work with regular HTML inputs since getInputProps returns valid HTMLAttributes
Errors display
Let’s add errors display:
import { type FormMetadata } from "@conform-to/react";
function Errors({ meta }: { meta?: Pick<FormMetadata, "errors"> }) {
if (!meta?.errors) return null;
return (
<div className="py-1 text-xs text-red-500">
{meta.errors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
);
}
// CreateTodoForm:
<>
<fetcher.Form {...getFormProps(form)}>
<Input
autoFocus
{...getInputProps(fields.title, { type: "text" })}
key={fields.title.key}
placeholder="Add a new todo"
/>
<Errors meta={fields.title} />
</fetcher.Form>
<Errors meta={form} />
</>;

Support multiple actions
Now the interesting part. I promise, please don’t fall asleep. We can use advanced Zod features to create more complex data structures, keeping typesafety and validation logic. Here we can use z.discriminatedUnion
to create a union of our schemas and validate the intent of the user.
Let’s add support for multiple actions to our form:
const createTodoSchema = zTodo.pick({ title: true });
const updateTodoSchema = zTodo.pick({ id: true, title: true, completed: true });
const deleteTodoSchema = zTodo.pick({ id: true });
const todoFormSchema = z.discriminatedUnion(
"intent",
[
createTodoSchema.extend({ intent: z.literal("create") }),
updateTodoSchema.extend({ intent: z.literal("update") }),
deleteTodoSchema.extend({ intent: z.literal("delete") }),
],
{
errorMap: (error, ctx) => {
if (error.code === "invalid_union_discriminator") {
return { message: "Invalid intent" };
}
return { message: "Unknown error" };
},
},
);
type TodoFormSchema = z.infer<typeof todoFormSchema>;
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, { schema: todoFormSchema });
if (submission.status !== "success") {
return submission.reply();
}
try {
switch (submission.value.intent) {
case "create":
await fakeApi.addTodo(submission.value);
break;
case "update":
await fakeApi.updateTodo(submission.value);
break;
case "delete":
await fakeApi.removeTodo(submission.value);
break;
}
} catch (error) {
return submission.reply({
formErrors: [error instanceof Error ? error.message : "Unknown error"],
});
}
return submission.reply({ resetForm: true });
}
Now, depending on the passed intent
, we will validate the form differently.
The intent
field, in turn, can be passed by assigning its value to the submit button:
// CreateTodoForm:
<Button
type="submit"
name={fields.intent.name} // Do not forget to pass the name of the field
value="create"
disabled={fetcher.state !== "idle"}
>
Add Todo
</Button>
Update Todo Form
Now, let’s create a form for updating a task:
// We're using the same form configuration as for the create form
// So we can reuse the same logic
function useTodoForm(defaultValue?: Omit<TodoFormSchema, "intent">) {
const fetcher = useFetcher<typeof action>();
const form = useForm<TodoFormSchema>({
defaultValue,
lastResult: fetcher.state === "idle" ? fetcher.data : null,
constraint: getZodConstraint(todoFormSchema),
onValidate({ formData }) {
return parseWithZod(formData, { schema: todoFormSchema });
},
});
return [fetcher, ...form] as const;
}
function CreateTodoForm() {
const [fetcher, form, fields] = useTodoForm();
return ...
}
function UpdateTodoForm({ todo }: { todo: Todo }) {
const [fetcher, form, fields] = useTodoForm(todo);
return (
<li className="rounded-md py-2">
<fetcher.Form
method="post"
{...getFormProps(form)}
className="flex space-x-2"
>
<Checkbox
{...getInputProps(fields.completed, { type: "checkbox" })}
type="button"
key={fields.completed.key}
className="mt-[10px]"
/>
{todo.id && (
<input
{...getInputProps(fields.id, { type: "hidden" })}
key={fields.id.key}
/>
)}
<div className="flex-1">
<Input
{...getInputProps(fields.title, { type: "text" })}
key={fields.title.key}
className={fields.completed.value && "line-through text-gray-500"}
placeholder="Todo title"
autoFocus
/>
<Errors meta={fields.title} />
</div>
<Button
type="submit"
name={fields.intent.name}
value="update"
disabled={!form.dirty || fetcher.state !== "idle"}
>
<span className="sr-only">Save</span>
<Save />
</Button>
<Button
type="submit"
name={fields.intent.name}
value="delete"
variant="destructive"
disabled={fetcher.state !== "idle"}
>
<span className="sr-only">Delete</span>
<Trash2 />
</Button>
</fetcher.Form>
<Errors meta={form} />
</li>
);
}
export default function Todos() {
const { todos } = useLoaderData<typeof loader>();
return (
<div className="p-6 w-md mx-auto flex flex-col space-y-4">
<CreateTodoForm />
<div className="flex-1">
<ul>
{todos.map((todo) => (
<UpdateTodoForm key={todo.id} todo={todo} />
))}
</ul>
</div>
</div>
);
}

And look, we have a form for updating and deleting a task using the only one handler and the same form configuration! Not bad, right?
Accessibility
One of the great advantages of Conform is its native support for accessibility. Let’s improve our Errors
component:
function Errors({ meta }: { meta?: Pick<FormMetadata, "errors" | "errorId"> }) {
if (!meta?.errors) return null;
return (
<div className="py-1 text-xs text-red-500">
{meta.errors.map((error) => (
<div key={error} id={meta.errorId}>
{error}
</div>
))}
</div>
);
}
Here we added id={meta.errorId}
to the error message
getInputProps
automatically adds aria-invalid="true"
and aria-describedby="error-id"
to the fields with errors, like getFormProps
does the same for the form element.
This ensures the correct association between the field and the error message for screen readers.
Since Conform doesn’t change native form behavior, it works great with native components and provides good accessibility. The getFormProps
and getInputProps
functions automatically add the necessary ARIA attributes, and the meta.errorId
field allows you to associate the error message with the corresponding field.
Final code sample
// fakedb.ts
import { z } from "zod";
export const zTodo = z.object(
{
id: z
.number({
required_error: "Id is required",
description: "The unique identifier for the todo",
})
.int()
.positive(),
title: z
.string({
required_error: "Title is required",
description: "The title of the todo",
})
.min(3, {
message: "Title must be at least 3 characters long",
}),
completed: z.coerce.boolean({
required_error: "Completed is required",
description: "Whether the todo has been completed",
}),
},
{ description: "A todo item" },
);
export type Todo = z.infer<typeof zTodo>;
let fakeTodos: Todo[] = [
{ id: 1, title: "Hold my beer", completed: true },
{ id: 2, title: "Build convenient form", completed: false },
];
const wait = (ms = 300) => new Promise((resolve) => setTimeout(resolve, ms));
const getTodos = async () => {
await wait();
return fakeTodos;
};
const addTodo = async ({ title }: { title: string }) => {
await wait();
const newTodo = { id: fakeTodos.length + 1, title, completed: false };
fakeTodos.push(newTodo);
return newTodo;
};
const removeTodo = async ({ id }: { id: number }) => {
await wait();
fakeTodos = fakeTodos.filter((todo) => todo.id !== id);
};
const updateTodo = async ({
id,
title,
completed,
}: {
id: number;
title: string;
completed: boolean;
}) => {
await wait();
const todo = fakeTodos.find((todo) => todo.id === id);
if (!todo) return;
todo.title = title;
todo.completed = completed;
};
export const fakeApi = { getTodos, addTodo, removeTodo, updateTodo };
// routes/home.tsx
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { fakeApi, zTodo, type Todo } from "../fakedb";
import {
useFetcher,
useLoaderData,
type ActionFunctionArgs,
} from "react-router";
import { z } from "zod";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import {
getFormProps,
getInputProps,
useForm,
type FormMetadata,
} from "@conform-to/react";
import { Checkbox } from "../components/ui/checkbox";
import { Save, Trash2 } from "lucide-react";
export async function loader() {
const todos = await fakeApi.getTodos();
return { todos };
}
const createTodoSchema = zTodo.pick({ title: true });
const updateTodoSchema = zTodo.pick({ id: true, title: true, completed: true });
const deleteTodoSchema = zTodo.pick({ id: true });
const todoFormSchema = z.discriminatedUnion(
"intent",
[
createTodoSchema.extend({ intent: z.literal("create") }),
updateTodoSchema.extend({ intent: z.literal("update") }),
deleteTodoSchema.extend({ intent: z.literal("delete") }),
],
{
errorMap: (error, ctx) => {
if (error.code === "invalid_union_discriminator") {
return { message: "Invalid intent" };
}
return { message: "Unknown error" };
},
},
);
type TodoFormSchema = z.infer<typeof todoFormSchema>;
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, { schema: todoFormSchema });
if (submission.status !== "success") {
return submission.reply();
}
try {
switch (submission.value.intent) {
case "create":
await fakeApi.addTodo(submission.value);
break;
case "update":
await fakeApi.updateTodo(submission.value);
break;
case "delete":
await fakeApi.removeTodo(submission.value);
break;
}
} catch (error) {
return submission.reply({
formErrors: [error instanceof Error ? error.message : "Unknown error"],
});
}
return submission.reply({ resetForm: true });
}
function useTodoForm(defaultValue?: Omit<TodoFormSchema, "intent">) {
const fetcher = useFetcher<typeof action>();
const form = useForm<TodoFormSchema>({
defaultValue,
lastResult: fetcher.state === "idle" ? fetcher.data : null,
constraint: getZodConstraint(todoFormSchema),
onValidate({ formData }) {
return parseWithZod(formData, { schema: todoFormSchema });
},
});
return [fetcher, ...form] as const;
}
function Errors({ meta }: { meta?: Pick<FormMetadata, "errors" | "errorId"> }) {
if (!meta?.errors) return null;
return (
<div className="py-1 text-xs text-red-500">
{meta.errors.map((error) => (
<div key={error} id={meta.errorId}>
{error}
</div>
))}
</div>
);
}
function CreateTodoForm() {
const [fetcher, form, fields] = useTodoForm();
return (
<div>
<fetcher.Form
method="post"
{...getFormProps(form)}
className="flex space-x-2"
>
<div className="flex-1">
<Input
autoFocus
{...getInputProps(fields.title, { type: "text" })}
key={fields.title.key}
placeholder="Add a new todo"
/>
<Errors meta={fields.title} />
</div>
<div>
<Button
type="submit"
name={fields.intent.name}
value={"create"}
disabled={fetcher.state !== "idle"}
>
<span className="sr-only">Add Todo</span>
Add Todo
</Button>
<Errors meta={fields.intent} />
</div>
</fetcher.Form>
<Errors meta={form} />
</div>
);
}
function UpdateTodoForm({ todo }: { todo: Todo }) {
const [fetcher, form, fields] = useTodoForm(todo);
return (
<li className="rounded-md py-2">
<fetcher.Form
method="post"
{...getFormProps(form)}
className="flex space-x-2"
>
<Checkbox
{...getInputProps(fields.completed, { type: "checkbox" })}
type="button"
key={fields.completed.key}
className="mt-[10px]"
/>
{todo.id && (
<input
{...getInputProps(fields.id, { type: "hidden" })}
key={fields.id.key}
/>
)}
<div className="flex-1">
<Input
{...getInputProps(fields.title, { type: "text" })}
key={fields.title.key}
className={fields.completed.value && "text-gray-500 line-through"}
placeholder="Todo title"
autoFocus
/>
<Errors meta={fields.title} />
</div>
<Button
type="submit"
name={fields.intent.name}
value="update"
disabled={!form.dirty || fetcher.state !== "idle"}
>
<span className="sr-only">Save</span>
<Save />
</Button>
<Button
type="submit"
name={fields.intent.name}
value="delete"
variant="destructive"
disabled={fetcher.state !== "idle"}
>
<span className="sr-only">Delete</span>
<Trash2 />
</Button>
</fetcher.Form>
<Errors meta={form} />
</li>
);
}
export default function Todos() {
const { todos } = useLoaderData<typeof loader>();
return (
<div className="mx-auto flex w-md flex-col space-y-4 p-6">
<CreateTodoForm />
<div className="flex-1">
<ul>
{todos.map((todo) => (
<UpdateTodoForm key={todo.id} todo={todo} />
))}
</ul>
</div>
</div>
);
}
Next part
Hope you’re had a little fun, In the next part we’ll dive into array fields and nested objects with drag and drop reordering.