Using Form Objects inside Remix actions

In Remix, each route can export a single action function used to handle any non GET request received by that route. This is used together with HTML forms as the endpoint the form is going to POST to after a submit.

// routes/login.tsx
export let action: ActionFunction = async ({ request }) => {
  let session = await getSession(request.headers.get("Cookie"));
  // perform the login here and store something in the session
  return redirect("/dashboard", {
    headers: { "Set-Cookie": await commitSession(session) },
  });
};

export default function Route() {
  return (
    <form action="/login" method="post">
      <label htmlFor="email">Email</label>
      <input type="email" id="email" required name="email" />
      <label htmlFor="password">Password</label>
      <input type="password" id="password" required name="password" />
      <button>Log In</button>
    </form>
  );
}

There are two main pain points here, which are not that big anyway and not an issue most of the time.

  1. Testing an action requires importing it from the route or using an E2E test
  2. The action handle not only POST but PUT/PATCH/DELETE requests too, if we use the Form component we can use those methods too

Form Objects to the rescue

A Form Object is, usually, a class with all the logic required to validate and do whatever is needed to handle a form, this is used in frameworks like Ruby on Rails to have validations specific for a single form of the app in an easy to test class, without adding more validations to the Model or worst, to the Controller.

How can this help us in Remix? The action in Remix is basically a controller, so we can use a Form Object to move the validations and logic of the form to an external object we can test without running the whole application.

Let's create a form function we can use to instantiate a new Form Object, we are not going to use classes here since a function will be enough. We will also directly integrate it with yup since it's a really simple to use schema validation libray.

// This types represent the function we use to do the logic required for the form
// This function receives the body of the form (already validated) and the session
// and returns the same value of an action, so we need to return a redirect
type PerformFunction<Body> = (
  body: Body,
  session: Session
) => Promise<ActionReturn> | ActionReturn;

// Our perform function could fail, so we can handle errors here
// This function receives the error, and the session and returns the same
// value of an action, so we need to return a redirect
type RescueFunction<Error extends globalThis.Error> = (
  error: Error,
  session: Session
) => Promise<ActionReturn> | ActionReturn;

// This is what the form function will receive as parameters
// here we receive the perform and the rescue
type FormInput<
  Schema extends yup.BaseSchema,
  Error extends globalThis.Error = globalThis.Error
> = {
  perform: PerformFunction<yup.Asserts<Schema>>;
  // we can see here how the rescue function will always receive
  // the error type we defined in our Error generic or the ValidationError
  // from the validation libary yup
  rescue: RescueFunction<Error | yup.ValidationError>;
};

export function form<
  // we receive a generic with the possible errors, it's the first to force
  // us to add it
  Error extends globalThis.Error,
  Schema extends yup.BaseSchema = yup.BaseSchema
>(schema: Schema, { perform, rescue }: FormInput<Schema, Error>) {
  return async (request: Request, session?: Session) => {
    // first we need to parse the body of the request as a JSON object
    // and get the session from the cookie, if we already received the session
    // we can also avoid that
    let [body, _session] = await Promise.all([
      bodyParser.json<Record<string, unknown>>(request),
      session
        ? Promise.resolve(session)
        : getSession(request.headers.get("Cookie")),
    ]);

    try {
      // then we use the schema to validate the body and pass the validated body
      // to the `perform` function, if either the validation or perform throw an
      // error we will handle it below
      let validBody = (await schema.validate(body)) as yup.Asserts<Schema>;
      return await perform(validBody, _session);
    } catch (error) {
      // here we catch any error and pass it to rescue, along the session
      return await rescue(error as Error, _session);
    }
  };
}

Let's see how we can use it to implement simple authentication form.

// forms/authentication-form.server.ts

// this is our schema, here we define what attributes we expect from the form
// and validates them however we need
let schema = yup.object().shape({
  email: yup.string().email().required(),
  password: yup.string().min(8).required(),
});

export default form<Error, typeof schema>(schema, {
  async perform(body, session) {
    // fake hash function
    let password = hash(body.password);
    // fake ORM
    let user = User.findBy({ email: body.email, password });
    // if we can't find the user throw an error, it will be handle in `rescue`
    if (!user) throw new Error("User or password are incorrect.");
    // store something in the session
    session.set("token", user.token);
    // redirect to the final route
    return redirect("/dashboard", {
      headers: { "Set-Cookie": await commitSession(session) },
    });
  },

  async rescue(error, session) {
    // we can handle specific errors here and add flash messages
    if (error instanceof yup.ValidationError) {
      session.flash("validation:error", error.message);
    } else {
      session.flash("generic:error", error.message);
    }

    // and return a redirect
    return redirect("/login", {
      headers: { "Set-Cookie": await commitSession(session) },
    });
  },
});

And finally, we can update our action to use this form.

import authenticationForm from "../forms/authentication-form.server";

export let action: ActionFunction = async ({ request }) => {
  // this is all we need to add here, it's so simple we could even use the same
  // action to handle more than one form, for example a PUT/PATCH or DELETE request
  // and use a condition based on the method to call different form function
  return authenticationForm(request);
};

And we can also test our form.

describe("Authentication Form", () => {
  test("redirects to /dashboard if it's a success", async () => {
    let request = new Request("/login", {
      method: "POST",
      body: new URLSearchParams({
        email: "some@email.com",
        password: "12345678",
      }),
    });
    let response = authenticationForm(request);
    expect(response.headers.get("Location")).toBe("/dashboard");
  });

  test("redirects to /login if it's a failure", async () => {
    let request = new Request("/login", {
      method: "POST",
      // password will not pass validation
      body: new URLSearchParams({
        email: "some@email.com",
        password: "123456",
      }),
    });
    let response = authenticationForm(request);
    expect(response.headers.get("Location")).toBe("/login");
  });

  // you can add more test scenarios for different validations or if the user doesn't exiss
});

As you can see, extracting the logic of the form handling to an external function we could test it easily, simplify our routes code and force us to handle more error cases by forcing the rescue function.