Nefe James Nefe is a frontend developer who enjoys learning new things and sharing his knowledge with others.

How to validate forms in Remix

7 min read 2161

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’s approach to 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.

The Form component

Remix 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.

We made a custom demo for .
No really. Click here to check it out.

Setting up a form in Remix

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 = useLoaderData(); //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.

Validating forms in Remix

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 (!/^[^\[email protected]][email protected][^\[email protected]]+\.[^\[email protected]]+$/.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.

Validating Remix forms with Remix Validated Form

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 form
  • resetAfterSubmit, a boolean that resets the form to the default values after the form has been successfully submitted

Putting 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>
  );
}

Conclusion

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.

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
Nefe James Nefe is a frontend developer who enjoys learning new things and sharing his knowledge with others.

Leave a Reply