Tutorial

In this tutorial, we will show you how to enhance a contact form with Conform.

Installation

Before start, please install conform on your project:

npm install @conform-to/react @conform-to/zod --save

Initial setup

Let's build a simple contact form with Remix.

1import { type ActionArgs, json } 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  email: z
8    .string({ required_error: 'Email is required' })
9    .email('Email is invalid'),
10  message: z.string({ required_error: 'Message is required' }),
11});
12
13export async function action({ request }: ActionArgs) {
14  const formData = await request.formData();
15
16  // Parse the form data
17  const payload = Object.fromEntries(formData);
18  const result = schema.safeParse(payload);
19
20  if (!result.success) {
21    return json({
22      payload,
23      error: result.error.flatten().fieldErrors,
24    });
25  }
26
27  return await sendMessage(result.data);
28}
29
30export default function ContactUs() {
31  const result = useActionData<typeof action>();
32
33  return (
34    <Form method="post">
35      <div>
36        <label>Email</label>
37        <input type="email" name="email" defaultValue={result?.payload.email} />
38        <div>{result?.error.email}</div>
39      </div>
40      <div>
41        <label>Message</label>
42        <textarea name="message" defaultValue={result?.payload.message} />
43        <div>{result?.error.message}</div>
44      </div>
45      <button>Send</button>
46    </Form>
47  );
48}
49

Introducing Conform

Now, it's time to enhance it using Conform.

1import { useForm } from '@conform-to/react';
2import { parse } from '@conform-to/zod';
3import { type ActionArgs, json } from '@remix-run/node';
4import { Form, useActionData } from '@remix-run/react';
5import { z } from 'zod';
6import { sendMessage } from '~/message';
7
8const schema = z.object({
9  // ...
10});
11
12export async function action({ request }: ActionArgs) {
13  const formData = await request.formData();
14
15  // Replace `Object.fromEntries()` with the parse function
16  const submission = parse(formData, { schema });
17
18  // Report the submission to client
19  // 1) if the intent is not `submit`, or
20  // 2) if there is any error
21  if (submission.intent !== 'submit' || !submission.value) {
22    return json(submission);
23  }
24
25  return await sendMessage(submission.value);
26}
27
28export default function ContactUs() {
29  const lastSubmission = useActionData<typeof action>();
30
31  // The `useForm` hook will return everything you need to setup a form
32  // including the error and config of each field
33  const [form, fields] = useForm({
34    // The last submission will be used to report the error and
35    // served as the default value and initial error of the form
36    // for progressive enhancement
37    lastSubmission,
38
39    // Validate the field once a `blur` event is triggered
40    shouldValidate: 'onBlur',
41  });
42
43  return (
44    <Form method="post" {...form.props}>
45      <div>
46        <label>Email</label>
47        <input
48          type="email"
49          name="email"
50          defaultValue={fields.email.defaultValue}
51        />
52        <div>{fields.email.errors}</div>
53      </div>
54      <div>
55        <label>Message</label>
56        <textarea name="message" defaultValue={fields.message.defaultValue} />
57        <div>{fields.message.errors}</div>
58      </div>
59      <button>Send</button>
60    </Form>
61  );
62}
63

Conform will trigger a server validation to validate each field whenever user leave the input (i.e. onBlur). It also focuses on the first invalid field on submit.

Setting client validation

Server validation might some time be too slow for a good user experience. We can also reuse the validation logic on the client for a instant feedback.

1import { parse, useForm } from '@conform-to/react';
2import { type ActionArgs, json } from '@remix-run/node';
3import { Form, useActionData } from '@remix-run/react';
4import { sendMessage } from '~/message';
5
6const schema = z.object({
7  // ...
8});
9
10export async function action({ request }: ActionArgs) {
11  // ...
12}
13
14export default function ContactUs() {
15  const lastSubmission = useActionData<typeof action>();
16  const [form, fields] = useForm({
17    lastSubmission,
18    shouldValidate: 'onBlur',
19
20    // Run the same validation logic on client
21    onValidate({ formData }) {
22      return parse(formData, { schema });
23    },
24  });
25
26  // ...
27}
28

Making it accessible

There is more we need do to make a form accessible. For example:

  • Set an id for each field and use it as the for attribute of the label
  • Set an aria-invalid attribute of the field to true when there is an error
  • Set an id for the error message and use it as the aria-describedby attribute of the field when there is an error
1import { parse, useForm } from '@conform-to/react';
2import { type ActionArgs, json } from '@remix-run/node';
3import { Form, useActionData } from '@remix-run/react';
4import { sendMessage } from '~/message';
5
6const schema = z.object({
7  // ...
8});
9
10export async function action({ request }: ActionArgs) {
11  // ...
12}
13
14export default function LoginForm() {
15  const lastSubmission = useActionData<typeof action>();
16  const [form, fields] = useForm({
17    // ...
18  });
19
20  return (
21    <Form method="post" {...form.props}>
22      <div>
23        <label htmlFor="email">Email</label>
24        <input
25          id="email"
26          type="email"
27          name="email"
28          defaultValue={fields.email.defaultValue}
29          aria-invalid={fields.email.errors.length > 0 || undefined}
30          aria-describedby={
31            fields.email.errors.length > 0 ? 'email-error' : undefined
32          }
33        />
34        <div id="email-error">{fields.email.errors}</div>
35      </div>
36      <div>
37        <label htmlFor="message">Message</label>
38        <textarea
39          id="message"
40          name="message"
41          defaultValue={fields.message.defaultValue}
42          aria-invalid={fields.message.errors.length > 0 || undefined}
43          aria-describedby={
44            fields.message.errors.length > 0 ? 'message-error' : undefined
45          }
46        />
47        <div id="message-error">{fields.message.error}</div>
48      </div>
49      <button>Send</button>
50    </Form>
51  );
52}
53

How about letting Conform manage all these ids for us?

1import { conform, useForm } from '@conform-to/react';
2import { parse } from '@conform-to/zod';
3import { type ActionArgs, json } from '@remix-run/node';
4import { Form, useActionData } from '@remix-run/react';
5import { useId } from 'react';
6import { sendMessage } from '~/message';
7
8const schema = z.object({
9  // ...
10});
11
12export async function action({ request }: ActionArgs) {
13  // ...
14}
15
16export default function LoginForm() {
17  const lastSubmission = useActionData<typeof action>();
18
19  // Generate a unique id for the form, or you can pass in your own
20  // Note: useId() is only available in React 18
21  const id = useId();
22  const [form, fields] = useForm({
23    // Let Conform manage all ids for us
24    id,
25
26    lastSubmission,
27    shouldValidate: 'onBlur',
28    onValidate({ formData }) {
29      return parse(formData, { schema });
30    },
31  });
32
33  return (
34    <Form method="post" {...form.props}>
35      <div>
36        <label htmlFor={fields.email.id}>Email</label>
37        {/* This derives attributes required by the input, such as type, name and default value */}
38        <input {...conform.input(fields.email, { type: 'email' })} />
39        <div id={fields.email.errorId}>{fields.email.errors}</div>
40      </div>
41      <div>
42        <label htmlFor={fields.message.id}>Message</label>
43        {/* It also manages id, aria attributes, autoFocus and validation attributes for us! */}
44        <textarea {...conform.textarea(fields.message)} />
45        <div id={fields.message.errorId}>{fields.message.errors}</div>
46      </div>
47      <button>Send</button>
48    </Form>
49  );
50}
51