Remix removes the need to manually hook up forms to the state, or to handle form submission on the client side with a submit
event listener like in a typical React application. Instead, Remix takes us back to the traditional way forms were handled in languages like PHP.
When handling forms with React, we have to set up state form data and errors, decide if we want to use controlled or uncontrolled variables, and handle onChange
, onBlur
, and onSubmit
events.
In this article, we will learn how forms work in Remix and the different ways to validate them. We will also learn how to set up custom validation and how to validate forms with Remix Validated Form.
Finally, we will look at the custom Form
component Remix provides, and how Remix takes a different approach from React when it comes to handling forms.
Remix brings back the traditional method of handling forms.
Remix provides functions (called action
and loader
) that we can use to perform server-side operations and access a form’s data. With these functions, we no longer need to serve JavaScript to the frontend to submit a form, thereby reducing the browser’s load.
In other frameworks, we might need to serve JavaScript to make a fetch
or an axios
call, but we don’t need to do that in Remix. It helps keep things simple.
Form
componentRemix provides a custom Form
component that works identically to the native HTML <form>
element. Unlike React forms, where we have to set up onChange
, onSubmit
, or onClick
event handlers, we don’t need to do that when working with Remix. Also, we do not need to set up state for our forms because we can access the form data from the web’s formData()
API.
Form
is a Remix-aware and enhanced HTML form component. It behaves like a normal form except that the interaction with the server is with fetch
instead of new document requests, allowing components to add a better user experience to the page as the form is submitted and returns with data.
Form
will automatically do a POST
request to the current page route. However, we can configure it for PUT
and DELETE
requests as well. An action method is needed to handle the requests from a form.
Let’s see what a basic form looks like in Remix:
import { Form, useActionData } from "remix"; export async function action({ request }) { //here, do something with the form data and return a value } export default function Sign() { const data = useActionData(); //we access the return value of the action here return ( <Form method="post" style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }} > <div> <label> Name: <input name="name" type="text" /> </label> </div> <div> <label> Email: <input name="email" type="email" /> </label> </div> <div> <label> Password: <input name="password" type="password" /> </label> </div> <div> <label> Confirm Password: <input name="confirmPassword" type="password" /> </label> </div> <button type="submit">Create Account</button> </Form> ); }
Here, we use Remix’s Form
component and the useActionData
Hook, which we will use later on. When a user submits a form, Remix automatically makes a POST request containing the form data to the server using the fetch API.
useActionData
returns the JSON parsed data from a route’s action. It is most commonly used when handling form validation errors.
Actions are functions that run only on the server when we submit a form. The action is called for POST
, PATCH
, PUT
, and DELETE
methods because actions are meant to modify or mutate data.
First, let’s set up the validation logic for the form fields:
const validateName = (name) => { if (!name) { return "Name is required"; } else if (typeof name !== "string" || name.length < 3) { return `Name must be at least 3 characters long`; } }; const validateEmail = (email) => { if (!email) { return "Email is Required"; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return "Invalid emaill address"; } }; const validatePassword = (password) => { if (!password) { return "Password is required"; } else if (typeof password !== "string" || password.length < 6) { return `Passwords must be at least 6 characters long`; } }; const validateComfirmPassword = (password, confirmPassword) => { if (!confirmPassword) { return "Confirm Password is required"; } else if (password !== confirmPassword) { return "Password does not match"; } };
The validation logic is straightforward. We check if the input fields are empty, or if they meet a certain set of requirements. If they fail these checks, we return error messages.
Next, we set up the action for the form:
export const action = async ({ request }) => { const data = Object.fromEntries(await request.formData()); console.log(data); // outputs { name: '', email: '', password: '', confirmPassword: '' } const formErrors = { name: validateName(data.name), email: validateEmail(data.email), password: validatePassword(data.password), confirmPassword: validateComfirmPassword( data.password, data.confirmPassword ), }; //if there are errors, we return the form errors if (Object.values(formErrors).some(Boolean)) return { formErrors }; //if there are no errors, we return the form data return { data }; };
Here, we create a formErrors
object and pass in the return value of the validation functions to their respective keys.
Then, check if there are any errors, and return formErrors
, otherwise we return the data. In a real-world project, we would redirect the user to another route.
Finally, let’s hook the action up to our form and display the errors, if there are any:
export default function Sign() { const actionData = useActionData(); return ( <Form method="post" style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }} > <div> <label> Name: <input name="name" type="text" /> </label> {actionData?.formErrors?.name ? ( <p style={{ color: "red" }}>{actionData?.formErrors?.name}</p> ) : null} </div> <div> <label> Email: <input name="email" type="email" /> </label> {actionData?.formErrors?.email ? ( <p style={{ color: "red" }}>{actionData?.formErrors?.email}</p> ) : null} </div> <div> <label> Password: <input name="password" type="password" /> </label> {actionData?.formErrors?.password ? ( <p style={{ color: "red" }}>{actionData?.formErrors?.password}</p> ) : null} </div> <div> <label> Confirm Password: <input name="confirmPassword" type="password" /> </label> {actionData?.formErrors?.confirmPassword ? ( <p style={{ color: "red" }}> {actionData?.formErrors?.confirmPassword} </p> ) : null} </div> <button type="submit">Create Account</button> </Form> ); }
Here, we access the formErrors
object from actionData
and conditionally render the appropriate form errors for each field.
Putting it all together, we have our final code below:
import { Form, useActionData } from "remix"; const validateName = (name) => { //validation logic here }; const validateEmail = (email) => { //validation logic here }; const validatePassword = (password) => { //validation logic here }; const validateComfirmPassword = (password, confirmPassword) => { //validation logic here }; export const action = async ({ request }) => { const data = Object.fromEntries(await request.formData()); const formErrors = { name: validateName(data.name), email: validateEmail(data.email), password: validatePassword(data.password), confirmPassword: validateComfirmPassword( data.password, data.confirmPassword ), }; if (Object.values(formErrors).some(Boolean)) return { formErrors }; return { data }; }; export default function Sign() { const actionData = useActionData(); return ( <Form method="post" style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }} > <div> <label> Name: <input name="name" type="text" /> </label> {actionData?.formErrors?.name ? ( <p style={{ color: "red" }}>{actionData?.formErrors?.name}</p> ) : null} </div> <div> <label> Email: <input name="email" type="" /> </label> {actionData?.formErrors?.email ? ( <p style={{ color: "red" }}>{actionData?.formErrors?.email}</p> ) : null} </div> <div> <label> Password: <input name="password" type="password" /> </label> {actionData?.formErrors?.password ? ( <p style={{ color: "red" }}>{actionData?.formErrors?.password}</p> ) : null} </div> <div> <label> Confirm Password: <input name="confirmPassword" type="password" /> </label> {actionData?.formErrors?.confirmPassword ? ( <p style={{ color: "red" }}> {actionData?.formErrors?.confirmPassword} </p> ) : null} </div> <button type="submit">Create Account</button> </Form> ); }
With that, we have successfully set up custom validation of a Remix form. While this works, it does not fully cater to all possible form validation needs.
For example, the validation logic runs only when we submit the form. Ideally, we should validate our forms when the user types or focuses out of a field. We could set up this logic, but that would be tedious to code, and we would also need to address several accessibility concerns.
Luckily for us, there is a library that we can use to properly handle the validation of Remix forms.
Remix Validated Form (RVF for short) provides a Form
component and utilities used to validate Remix forms.
RVF is validation library agnostic. It has official adapters for Yup and Zod, but we can create our own adapters to support the validation library of our choice.
Let’s see how to use RVF.
First, we set up a custom Input
component, like so:
import { useField } from "remix-validated-form"; export const Input = ({ name, label }) => { const { error, getInputProps } = useField(name); return ( <div> <label htmlFor={name}> {label}: {""} </label> <input {...getInputProps({ id: name })} /> {error && <p style={{ color: "red" }}>{error}</p>} </div> ); };
The useField
hook returns getInputProps
which is a prop-getter, and a validation error message if one exists. We pass in the name and label of the input and conditionally render the error message.
Next, we set up a custom SubmitBtn
component:
import { useIsSubmitting } from "remix-validated-form"; export const SubmitBtn = () => { const isSubmitting = useIsSubmitting(); return ( <button type="submit" disabled={isSubmitting}> {isSubmitting ? "Submitting..." : "Submit"} </button> ); };
useIsSubmitting
returns an isSubmitting
boolean that informs us when a submit event is taking place (when the user is submitting the form).
Now, set up a validator
that RVF will use in the background to validate the form fields. We will use Zod to create the validation schema:
export const validator = withZod( z .object({ name: z .string() .nonempty("Name is required") .min(3, "Name must be at least 3 characters long"), email: z .string() .nonempty("Email is required") .email("Invalid emaill address"), password: z .string() .nonempty("Password is required") .min(6, "Password must be at least 6 characters long"), confirmPassword: z.string(), }) .refine(({ password, confirmPassword }) => password === confirmPassword, { message: "Passwords must match", path: ["confirmPassword"], }) );
Next we create an action for the form:
export const action = async ({ request }) => { const result = await validator.validate(await request.formData()); if (result.error) { // validationError comes from `remix-validated-form` return validationError(result.error); } return result; };
This will return the errors if any exist, or else return the form data.
Now, let’s put Input
, SubmitBtn
, validator
, and the action we created earlier together to create a sign up form:
export default function Sign() { const actionData = useActionData(); console.log(actionData); return ( <ValidatedForm validator={validator} method="post" defaultValues={{ name: "Nefe", email: "[email protected]" }} style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }} > <Input name="name" label="Name" /> <Input name="email" label="Email" /> <Input name="password" label="Password" /> <Input name="confirmPassword" label="Confirm Password" /> <SubmitBtn /> </ValidatedForm> ); }
ValidatedForm
is RVF’s primary form component. These are some of the props it accepts:
defaultValues
, an object containing the initial values of each form field (this is an optional field)validator
, an object that describes how to validate the formresetAfterSubmit
, a boolean that resets the form to the default values after the form has been successfully submittedPutting it all together, we have our final code below:
import { useActionData } from "remix"; import { ValidatedForm } from "remix-validated-form"; import { withZod } from "@remix-validated-form/with-zod"; import { SubmitBtn } from "~/components/submitBtn"; import { Input } from "~/components/Input"; import { z } from "zod"; export const validator = withZod( //validation logic here ); export const action = async ({ request }) => { const result = await validator.validate(await request.formData()); if (result.error) { return validationError(result.error); } return result; }; export default function Sign() { const actionData = useActionData(); return ( <ValidatedForm validator={validator} method="post" defaultValues={{ name: "Nefe", email: "[email protected]" }} style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }} > <Input name="name" label="Name" /> <Input name="email" label="Email" /> <Input name="password" label="Password" /> <Input name="confirmPassword" label="Confirm Password" /> <SubmitBtn /> </ValidatedForm> ); }
In this article, we have learned a new (but, in reality, old) way of managing forms on the web. We have seen how Remix’s approach differs from React when it comes to form handling forms.
We have also learned how to set up custom validation for our forms and how to validate them using Remix Validated Form.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowSOLID principles help us keep code flexible. In this article, we’ll examine all of those principles and their implementation using JavaScript.
JavaScript’s Date API has many limitations. Explore alternative libraries like Moment.js, date-fns, and the new Temporal API.
Explore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.
3 Replies to "How to validate forms in Remix"
There’s a typo in the ‘Setting up a form in Remix’ section.
`useLoaderData()` should be `useActiondata()`
Thanks for catching that typo
I wish there was a video of how it looks when you inputted invalid info…then I don’t have to run this locally to see