Tutorial
In this tutorial, we will start with a basic contact form built with just Remix and Zod. Then, we will show you how to enhance it using Conform.
#Installation
Before start, please install conform on your project.
npm install @conform-to/react @conform-to/zod --save
#Initial setup
First, let's define the schema. Here is a zod schema that we will use to validate the form data:
import { z } from 'zod';
const schema = z.object({
// The preprocess step is required for zod to perform the required check properly
// as the value of an empty input is usually an empty string
email: z.preprocess(
(value) => (value === '' ? undefined : value),
z.string({ required_error: 'Email is required' }).email('Email is invalid'),
),
message: z.preprocess(
(value) => (value === '' ? undefined : value),
z
.string({ required_error: 'Message is required' })
.min(10, 'Message is too short')
.max(100, 'Message is too long'),
),
});
In the action handler, we will parse the form data and validate it with zod. If there is any error, we will return it to the client together with the submitted value.
1import { type ActionFunctionArgs, redirect } from '@remix-run/node';
2import { z } from 'zod';
3import { sendMessage } from '~/message';
4
5const schema = z.object({
6 // ...
7});
8
9export async function action({ request }: ActionFunctionArgs) {
10 const formData = await request.formData();
11
12 // Construct an object using `Object.fromEntries`
13 const payload = Object.fromEntries(formData);
14 // Then parse it with zod
15 const result = schema.safeParse(payload);
16
17 // Return the error to the client if the data is not valid
18 if (!result.success) {
19 const error = result.error.flatten();
20
21 return {
22 payload,
23 formErrors: error.formErrors,
24 fieldErrors: error.fieldErrors,
25 };
26 }
27
28 // We will skip the implementation as it is not important to the tutorial
29 const message = await sendMessage(result.data);
30
31 // Return a form error if the message is not sent
32 if (!message.sent) {
33 return {
34 payload,
35 formErrors: ['Failed to send the message. Please try again later.'],
36 fieldErrors: {},
37 };
38 }
39
40 return redirect('/messages');
41}
Then, we will implement the contact form. If the submission result is returned from useActionData()
, we will display the error message next to each field. The fields are also initialized with the submitted value to persist the form data in case the document is reloaded.
1import { type ActionFunctionArgs } from '@remix-run/node';
2import { Form, useActionData } from '@remix-run/react';
3import { z } from 'zod';
4import { sendMessage } from '~/message';
5
6const schema = z.object({
7 // ...
8});
9
10export async function action({ request }: ActionFunctionArgs) {
11 // ...
12}
13
14export default function ContactUs() {
15 const result = useActionData<typeof action>();
16
17 return (
18 <Form method="POST">
19 <div>{result?.formErrors}</div>
20 <div>
21 <label>Email</label>
22 <input type="email" name="email" defaultValue={result?.payload.email} />
23 <div>{result?.fieldErrors.email}</div>
24 </div>
25 <div>
26 <label>Message</label>
27 <textarea name="message" defaultValue={result?.payload.message} />
28 <div>{result?.fieldErrors.message}</div>
29 </div>
30 <button>Send</button>
31 </Form>
32 );
33}
We are not done yet. Accessibility should never be overlooked. Let's make the form more accessible by adding the following attributes:
- Make sure each label is associated with the input properly with an unique id
- Setup validation attributes similar to the zod schema
- Configure the aria-invalid attribute of the form elements based on the validity
- Make sure the error message is linked to a form element with the aria-describedby attribute
1import { type ActionFunctionArgs } from '@remix-run/node';
2import { Form, useActionData } from '@remix-run/react';
3import { z } from 'zod';
4import { sendMessage } from '~/message';
5
6const schema = z.object({
7 // ...
8});
9
10export async function action({ request }: ActionFunctionArgs) {
11 // ...
12}
13
14export default function ContactUs() {
15 const result = useActionData<typeof action>();
16
17 return (
18 <Form
19 method="POST"
20 aria-invalid={result?.formErrors ? true : undefined}
21 aria-describedby={result?.formErrors ? 'contact-error' : undefined}
22 >
23 <div id="contact-error">{result?.formErrors}</div>
24 <div>
25 <label htmlFor="contact-email">Email</label>
26 <input
27 id="contact-email"
28 type="email"
29 name="email"
30 defaultValue={result?.payload.email}
31 required
32 aria-invalid={result?.error.email ? true : undefined}
33 aria-describedby={
34 result?.error.email ? 'contact-email-error' : undefined
35 }
36 />
37 <div id="contact-email-error">{result?.error.email}</div>
38 </div>
39 <div>
40 <label htmlFor="contact-message">Message</label>
41 <textarea
42 id="contact-message"
43 name="message"
44 defaultValue={result?.payload.message}
45 required
46 minLength={10}
47 maxLength={100}
48 aria-invalid={result?.error.message ? true : undefined}
49 aria-describedby={
50 result?.error.message ? 'contact-email-message' : undefined
51 }
52 />
53 <div id="contact-email-message">{result?.error.message}</div>
54 </div>
55 <button>Send</button>
56 </Form>
57 );
58}
This is a lot of work even for a simple contact form. It is also error-prone to maintains all the ids. How can we simplify it?
#Introduce Conform
This is where Conform comes in. To begin, we can remove the preprocess from the zod schema as Conform's zod integration will automatically strip empty string for you.
1import { z } from 'zod';
2
3const schema = z.object({
4 email: z
5 .string({ required_error: 'Email is required' })
6 .email('Email is invalid'),
7 message: z
8 .string({ required_error: 'Message is required' })
9 .min(10, 'Message is too short')
10 .max(100, 'Message is too long'),
11});
Then, we can simplify the action with the parseWithZod()
helper function. It will parse the form data and return a submission object with either the parsed value or the error.
1import { parseWithZod } from '@conform-to/zod';
2import { type ActionFunctionArgs } from '@remix-run/node';
3import { z } from 'zod';
4import { sendMessage } from '~/message';
5
6const schema = z.object({
7 // ...
8});
9
10export async function action({ request }: ActionFunctionArgs) {
11 const formData = await request.formData();
12
13 // Replace `Object.fromEntries()` with the parseWithZod helper
14 const submission = parseWithZod(formData, { schema });
15
16 // Report the submission to client if it is not successful
17 if (submission.status !== 'success') {
18 return submission.reply();
19 }
20
21 const message = await sendMessage(submission.value);
22
23 // Return a form error if the message is not sent
24 if (!message.sent) {
25 return submission.reply({
26 formErrors: ['Failed to send the message. Please try again later.'],
27 });
28 }
29
30 return redirect('/messages');
31}
Now, we can manage all the form metadata with the useForm hook. We will also derive the validation attributes from the zod schema using the getZodConstraint()
helper.
1import { useForm } from '@conform-to/react';
2import { parseWithZod, getZodConstraint } from '@conform-to/zod';
3import { type ActionFunctionArgs } from '@remix-run/node';
4import { Form, useActionData } from '@remix-run/react';
5import { z } from 'zod';
6import { sendMessage } from '~/message';
7import { getUser } from '~/session';
8
9const schema = z.object({
10 // ...
11});
12
13export async function action({ request }: ActionFunctionArgs) {
14 // ...
15}
16
17export default function ContactUs() {
18 const lastResult = useActionData<typeof action>();
19 // The useForm hook will return all the metadata we need to render the form
20 // and put focus on the first invalid field when the form is submitted
21 const [form, fields] = useForm({
22 // This not only syncs the error from the server
23 // But is also used as the default value of the form
24 // in case the document is reloaded for progressive enhancement
25 lastResult,
26
27 // To derive all validation attributes
28 constraint: getZodConstraint(schema),
29 });
30
31 return (
32 <Form
33 method="post"
34 {/* The only additional attribute you need is the `id` attribute */}
35 id={form.id}
36 aria-invalid={form.errors ? true : undefined}
37 aria-describedby={form.errors ? form.errorId : undefined}
38 >
39 <div id={form.errorId}>{form.errors}</div>
40 <div>
41 <label htmlFor={fields.email.id}>Email</label>
42 <input
43 id={fields.email.id}
44 type="email"
45 name={fields.email.name}
46 defaultValue={fields.email.initialValue}
47 required={fields.email.required}
48 aria-invalid={fields.email.errors ? true : undefined}
49 aria-describedby={
50 fields.email.errors ? fields.email.errorId : undefined
51 }
52 />
53 <div id={fields.email.errorId}>{fields.email.errors}</div>
54 </div>
55 <div>
56 <label htmlFor={fields.message.id}>Message</label>
57 <textarea
58 id={fields.message.id}
59 name={fields.message.name}
60 defaultValue={fields.message.initialValue}
61 required={fields.message.required}
62 minLength={fields.message.minLength}
63 maxLength={fields.message.maxLength}
64 aria-invalid={fields.message.errors ? true : undefined}
65 aria-describedby={
66 fields.message.errors ? fields.message.errorId : undefined
67 }
68 />
69 <div id={fields.message.errorId}>{fields.message.errors}</div>
70 </div>
71 <button>Send</button>
72 </Form>
73 );
74}
#Improve validation experience
Right now the contact form will be validated only when the user submit it. What if we want to give early feedback to the user as they type?
Let's setup the shouldValidate
and shouldRevalidate
options.
1import { useForm } from '@conform-to/react';
2import { parseWithZod } from '@conform-to/zod';
3import {
4 type ActionFunctionArgs,
5 type LoaderFunctionArgs,
6 json,
7} from '@remix-run/node';
8import { Form, useActionData, useLoaderData } from '@remix-run/react';
9import { sendMessage } from '~/message';
10import { getUser } from '~/session';
11
12const schema = z.object({
13 // ...
14});
15
16export async function loader({ request }: LoaderFunctionArgs) {
17 // ...
18}
19
20export async function action({ request }: ActionFunctionArgs) {
21 // ...
22}
23
24export default function ContactUs() {
25 const user = useLoaderData<typeof loader>();
26 const lastResult = useActionData<typeof action>();
27 const [form, fields] = useForm({
28 // ... previous config
29
30 // Validate field once user leaves the field
31 shouldValidate: 'onBlur',
32 // Then, revalidate field as user types again
33 shouldRevalidate: 'onInput',
34 });
35
36 // ...
37}
At this point, our contact form is only validated on the server and takes a round trip to the server to validate the form each time the user types. Let's shorten the feedback loop with client validation.
1import { useForm } from '@conform-to/react';
2import { parseWithZod } from '@conform-to/zod';
3import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
4import { Form, useActionData, useLoaderData } from '@remix-run/react';
5import { sendMessage } from '~/message';
6import { getUser } from '~/session';
7
8const schema = z.object({
9 // ...
10});
11
12export async function action({ request }: ActionFunctionArgs) {
13 // ...
14}
15
16export default function ContactUs() {
17 const user = useLoaderData<typeof loader>();
18 const lastResult = useActionData<typeof action>();
19 const [form, fields] = useForm({
20 // ... previous config
21
22 // Run the same validation logic on client
23 onValidate({ formData }) {
24 return parseWithZod(formData, { schema });
25 },
26 });
27
28 return (
29 <Form
30 method="post"
31 id={form.id}
32 {/* The `onSubmit` handler is required for client validation */}
33 onSubmit={form.onSubmit}
34 aria-invalid={form.errors ? true : undefined}
35 aria-describedby={form.errors ? form.errorId : undefined}
36 >
37 {/* ... */}
38 </Form>
39 );
40}
#Removing boilerplate
It's great that Conform can manage all the ids and validation attributes for us. However, it is still a lot of work to setup the form and fields. If you are dealing with native inputs, you can use helpers like getFormProps and getInputProps to minimize the boilerplate.
1import {
2 useForm,
3 getFormProps,
4 getInputProps,
5 getTextareaProps,
6} from '@conform-to/react';
7import { parseWithZod, getZodConstraint } from '@conform-to/zod';
8import { type ActionFunctionArgs } from '@remix-run/node';
9import { Form, useActionData } from '@remix-run/react';
10import { sendMessage } from '~/message';
11
12const schema = z.object({
13 // ...
14});
15
16export async function action({ request }: ActionFunctionArgs) {
17 // ...
18}
19
20export default function ContactUs() {
21 const lastResult = useActionData<typeof action>();
22 const [form, fields] = useForm({
23 // ...
24 });
25
26 return (
27 <Form method="post" {...getFormProps(form)}>
28 <div>
29 <label htmlFor={fields.email.id}>Email</label>
30 <input {...getInputProps(fields.email, { type: 'email' })} />
31 <div id={fields.email.errorId}>{fields.email.errors}</div>
32 </div>
33 <div>
34 <label htmlFor={fields.message.id}>Message</label>
35 <textarea {...getTextareaProps(fields.message)} />
36 <div id={fields.message.errorId}>{fields.message.errors}</div>
37 </div>
38 <button>Send</button>
39 </Form>
40 );
41}
That's it! Here is the complete example that we have built in this tutorial:
1import {
2 useForm,
3 getFormProps,
4 getInputProps,
5 getTextareaProps,
6} from '@conform-to/react';
7import { parseWithZod, getZodConstraint } from '@conform-to/zod';
8import { type ActionFunctionArgs } from '@remix-run/node';
9import { Form, useActionData } from '@remix-run/react';
10import { z } from 'zod';
11import { sendMessage } from '~/message';
12
13const schema = z.object({
14 email: z
15 .string({ required_error: 'Email is required' })
16 .email('Email is invalid'),
17 message: z
18 .string({ required_error: 'Message is required' })
19 .min(10, 'Message is too short')
20 .max(100, 'Message is too long'),
21});
22
23export async function action({ request }: ActionFunctionArgs) {
24 const formData = await request.formData();
25 const submission = parseWithZod(formData, { schema });
26
27 if (submission.status !== 'success') {
28 return submission.reply();
29 }
30
31 const message = await sendMessage(submission.value);
32
33 if (!message.sent) {
34 return submission.reply({
35 formErrors: ['Failed to send the message. Please try again later.'],
36 });
37 }
38
39 return redirect('/messages');
40}
41
42export default function ContactUs() {
43 const lastResult = useActionData<typeof action>();
44 const [form, fields] = useForm({
45 lastResult,
46 constraint: getZodConstraint(schema),
47 shouldValidate: 'onBlur',
48 shouldRevalidate: 'onInput',
49 onValidate({ formData }) {
50 return parseWithZod(formData, { schema });
51 },
52 });
53
54 return (
55 <Form method="post" {...getFormProps(form)}>
56 <div>
57 <label htmlFor={fields.email.id}>Email</label>
58 <input {...getInputProps(fields.email, { type: 'email' })} />
59 <div id={fields.email.errorId}>{fields.email.errors}</div>
60 </div>
61 <div>
62 <label htmlFor={fields.message.id}>Message</label>
63 <textarea {...getTextareaProps(fields.message)} />
64 <div id={fields.message.errorId}>{fields.message.errors}</div>
65 </div>
66 <button>Send</button>
67 </Form>
68 );
69}