Integration / React Router

React Router

Here is a login form example integrating with React Router in Framework Mode. For a full example, see the React Router example.

This guide uses Conform's future APIs. These APIs are experimental and may change in minor versions. Learn more

This example uses a route module at app/routes/login.tsx. The Route types are generated by React Router.

1import { useForm, parseSubmission, report } from '@conform-to/react/future';
2import { coerceFormValue } from '@conform-to/zod/v4/future';
3import { Form, redirect } from 'react-router';
4import { z } from 'zod';
5import type { Route } from './+types/login';
6
7const schema = coerceFormValue(
8  z.object({
9    email: z.string({ error: 'Email is required' }).email('Email is invalid'),
10    password: z.string({ error: 'Password is required' }),
11    remember: z.boolean().default(false),
12  }),
13);
14
15export async function action({ request }: Route.ActionArgs) {
16  const formData = await request.formData();
17  const submission = parseSubmission(formData);
18  const result = schema.safeParse(submission.payload);
19
20  if (!result.success) {
21    return {
22      result: report(submission, {
23        error: {
24          issues: result.error.issues,
25        },
26      }),
27    };
28  }
29
30  // ...
31
32  return redirect('/');
33}
34
35export default function Login({ actionData }: Route.ComponentProps) {
36  // Reuse the validation logic on the client
37  const { form, fields } = useForm(schema, {
38    // Sync the result of last submission
39    lastResult: actionData?.result,
40
41    // Validate the form on blur event triggered
42    shouldValidate: 'onBlur',
43    shouldRevalidate: 'onInput',
44  });
45
46  return (
47    <Form method="post" {...form.props}>
48      <div>
49        <label htmlFor={fields.email.id}>Email</label>
50        <input
51          id={fields.email.id}
52          type="email"
53          name={fields.email.name}
54          defaultValue={fields.email.defaultValue}
55          aria-invalid={!fields.email.valid || undefined}
56          aria-describedby={fields.email.ariaDescribedBy}
57        />
58        <div id={fields.email.errorId}>{fields.email.errors}</div>
59      </div>
60      <div>
61        <label htmlFor={fields.password.id}>Password</label>
62        <input
63          id={fields.password.id}
64          type="password"
65          name={fields.password.name}
66          defaultValue={fields.password.defaultValue}
67          aria-invalid={!fields.password.valid || undefined}
68          aria-describedby={fields.password.ariaDescribedBy}
69        />
70        <div id={fields.password.errorId}>{fields.password.errors}</div>
71      </div>
72      <div>
73        <label htmlFor={fields.remember.id}>Remember me</label>
74        <input
75          id={fields.remember.id}
76          type="checkbox"
77          name={fields.remember.name}
78          defaultChecked={fields.remember.defaultChecked}
79          aria-invalid={!fields.remember.valid || undefined}
80          aria-describedby={fields.remember.ariaDescribedBy}
81        />
82      </div>
83      <hr />
84      <button>Login</button>
85    </Form>
86  );
87}

#Tips

The default value might be out of sync if you reset the form from the action

If the default value of the form comes from the loader and you are trying to reset the form on the action, there is a chance you will see the form reset to the previous default value. As Conform will reset the form the moment action data is updated while React Router is still revalidating the loader data. To fix this, you can wait for the state to be idle (e.g. navigation.state or fetcher.state) before passing the lastResult to Conform like this:

For example, if the login route above has a loader that returns defaultValue, reuse the same schema and update the component as follows:

1import { useForm } from '@conform-to/react/future';
2import { useNavigation } from 'react-router';
3import type { Route } from './+types/login';
4
5export default function Login({
6  loaderData,
7  actionData,
8}: Route.ComponentProps) {
9  const { defaultValue } = loaderData;
10  const navigation = useNavigation();
11  const { form, fields } = useForm(schema, {
12    // If the default value comes from loader
13    defaultValue,
14
15    // Sync the result of last submission only when the state is idle
16    lastResult: navigation.state === 'idle' ? actionData?.result : null,
17
18    // or, if you are using a fetcher:
19    // lastResult: fetcher.state === 'idle' ? fetcher.data?.result : null,
20
21    // ...
22  });
23
24  // ...
25}