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.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
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>

Compare the top AI development tools and models of November 2025. View updated rankings, feature breakdowns, and find the best fit for you.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the November 5th issue.

A senior developer discusses how developer elitism breeds contempt and over-reliance on AI, and how you can avoid it in your own workplace.

Examine AgentKit, Open AI’s new tool for building agents. Conduct a side-by-side comparison with n8n by building AI agents with each tool.
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 now
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