
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
- Embrace the
<form>
- Write tests to make sure the expected inputs are in the form
- Transform
FormData
into an object usingextract-object-from-form-data
- Validate the object using
zod
- Update the DB using the validated object
- 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.