Working with Forms in Remix

Remix (and now React Router) handles data mutations in a different way than most modern web application frameworks. Remix applications use actual HTML form submissions to mutate data. There are a lot of great advantages to this approach, like automatically canceling in-flight submissions when the form is submitted again, and forcing the state to be managed by the server instead of the client. However, using FormData instead of 'application/json' can be difficult, even tedious.

const firstName = formData.get('firstName');
const lastName = formData.get('lastName');
const email = formData.get('email');
 
// etc. etc. etc.
 
const lastValue = formData.get('lastValue');

At Resonance, we have come up with a Remix workflow that is easy and repeatable. It includes form validation, error checking, and type safety.

Our Remix Form Workflow

  1. Embrace the <form>
  2. Write tests to make sure the expected inputs are in the form
  3. Transform FormData into an object using extract-object-from-form-data
  4. Validate the object using zod
  5. Update the DB using the validated object
  6. If the data is not valid, show the errors on the form

Embrace the <form>

Remix and React Router do provide other methods for mutating data, like useSubmit and useFetcher. These have their place, but relying heavily on them is denying the true power of the framework, which heavily leverages native browser capabilities. I recommend only using these other methods when <Form> just won’t work. If you are used to building SPAs with other frameworks, this will feel a little odd at first, but don’t give up. It’s worth it.

Why is it worth it? I think the Remix homepage explains it well: #useThePlatform. The web platform is mature, thorough, feature-rich, well-documented, well-tested, and well-supported. Take, for example, the "typeahead" or "combobox" pattern. If you use a <Form> to submit queries as the user types, the browser will automatically cancel the previous query when the user types the next character, which means that the only request that gets a response is the last one. With a client-side only approach, you have to keep track of state to make sure you are not showing stale data to the user. A lot of libraries will try to backfill these features, but if you can just use the platform, why wouldn't you?

Another huge advantage of using the platform, and specifically using actual form submissions, is that your application still works even if JavaScript is disabled (unlikely) or fails to load (much more likely). There are few web experiences more frustrating than a button that does nothing or a form that refuses to submit, even when you keep clicking "Submit". By using actual form submissions you can ensure that this never happens to your users. Remix and React Router let you have the both of best worlds, by submitting the forms asynchronously when JavaScript is available, and using HTML form submits when it is not.

Write tests

With a traditional SPA workflow, you will generally create an object that syncs with form inputs, but when the user actually goes to submit the form, you preventDefault and send the object using fetch instead of the actual values in the form inputs. When you write tests for this workflow, you need to make sure that the form inputs and object are syncing properly, and that all of the object properties are present when the form is submitted. Client-side state management is a pain.

With this Remix workflow, you can just test that all of the necessary form inputs are present in the form. If the correct inputs are present, then the form will submit the correct data. And you don’t need to worry about keeping anything synced. The value that gets submitted is just the value in the input.

test('the form has the correct inputs', () => {
  render(<SignupForm />);
  const firstNameInput = screen.getByLabelText('First Name');
  expect(firstNameInput).toBeInTheDocument();
  expect(firstNameInput).toHaveAttribute('name', 'firstName');
  const lastNameInput = screen.getByLabelText('Last Name');
  expect(lastNameInput).stoBeInTheDocument();
  expect(lastNameInput).toHaveAttribute('name', 'lastName');
  const emailInput = screen.getByLabelText('Email');
  expect(emailInput).stoBeInTheDocument();
  expect(emailInput).toHaveAttribute('name', 'email');
  // etc. etc. etc.
});

Transform FormData into an object

As we saw in the example above, handling the submission of a form that has a lot of input fields can be tedious. We have recently released a small library called extract-object-from-form-data that makes this a much easier process. It takes a FormData object and returns a plain JavaScript object.

This library can handle flat objects, nested objects, and arrays.

<input type="text" name="id" />
<input type="text" name="name.first" />
<input type="text" name="name.last" />
<input type="text" name="contact[0].email" />
<input type="text" name="contact[0].phone" />
<input type="text" name="contact[1].email" />
<!--  some other inputs here-->
<input type="text" name="lastValue" />
// When handling the form submission
const formData = await request.formData();
const userData = extractObjectFromFormData(formData);
 
/**
  Example output
{
  id: 'user-123',
  name: {
    first: 'John',
    last: 'Lennon'
  }
  contact: [
    {
      email: 'john@example.com',
      phone: '555-555-1234'
    },
    {
      email: 'lennon@example.com'
    }
  ],
  lastValue: 'the end'
}
*/

Validate the object using Zod

At this point, we know that our form has the correct inputs, and we have transformed the FormData into a plain JavaScript object. Now, we can do run-time validation to ensure that the data is ready to be added to our DB. We use zod to handle this, and we use the output to show errors on the form as well. Here is an example of a Zod schema built for a user signup form.

const userSchema = z.object({
  id: z.string(),
  name: z.object({
    first: z.string().min(1),
    last: z.string().min(1),
  }),
  contact: z.array(
    z.object({
      email: z.string().email(),
      phone: z.optional(z.string().min(10)),
    }),
  ),
});
 
type NewUserData = z.infer<typeof userSchema>;
export const validateUserData = (data: unknown) => {
  return newSegmentSchema.safeParse(data);
};

In our Remix route action function, we can use the validateUserData function to validate the data before putting it in the DB.

const { success, data: userData, error } = validateUserData(userData);
if (success) {
  const user = await createUser(userData);
  return redirect(`/users/${user.id}`);
}
// Return the errors to show on the form
return { error };

Update the DB

We use Prisma as our ORM. Prisma offers type safety, so we know that the data validated by our schema is safe to add to the DB.

type NewUserData = z.infer<typeof userSchema>;
 
// ... clipped for brevity
 
export const createUser = (userData: NewUserData) => {
  // If `NewUserData` were the wrong shape for the Prisma `user.create`
  // function, we would see a TypeScript error here.
  return prisma.user.create({ data: userData });
};

Show errors on the form

The final step of the process is showing helpful error messages in the UI. With the work we have already done, this is quite easy. As shown above, the Zod error object is returned from the action function whenever there is an error. We can use this object (after a slight transformation) to show which fields in the form need to be corrected.

This is the transformation function that we use. We have not released it as a package yet, but hopefully this snippet is at least helpful.

export const zodErrorToObject = (error: z.ZodError) => {
  return error.issues.reduce((res, issue) => {
    insertValueAtPath(
      res,
      { code: issue.code, message: issue.message },
      issue.path.join('.'),
    );
    return res;
  }, {});
};

Here is the full Remix route action function.

export const action = async ({ request }) => {
  const formData = await request.formData();
  const userData = extractObjectFromFormData(formData);
 
  const { success, data: userData, error } = validateUserData(userData);
  if (success) {
    const user = await createUser(userData);
    return redirect(`/users/${user.id}`);
  }
 
  return json({ errorData: zodErrorToObject(error) });
};

Here is how we use the error data in the UI. Our <Input> component adds a red border and shows the error message when the error prop is present.

export default UserSignup() {
  const actionData = useActionData<typeof action>();
 
  const errorData = actionData?.errorData;
 
  return (
    <Form method="post">
      <Input type="text" name="name.first" error={errorData?.name?.first} />
      <Input type="text" name="name.last" error={errorData?.name?.last} />
      // etc. etc. etc.
    </Form>
  )
}

Conclusion

At Resonance, we have found this workflow to be very effective. We avoid errors with invalid data being added to the DB, and we have a consistent way to show errors on the form. We hope you find this useful as well.


Resonance helps companies create personalized experiences for all of their user segments at the same engineering cost as a single experience. If your company is looking to create more personalized experiences for your users, let’s talk.