Multiple forms per route in Remix

Suppose you have a complex enough Remix application. In that case, you may have reached the point where a single route renders more than one form in the UI, but you can only export a single ActionFunction per route file. So you may wonder what to do in this case.

Using resource routes

One approach is to use resource routes. You could create a route like routes/forms/like.tsx and export an action there, like so:

In your Form:

import { Form } from "@remix-run/react";

function LikeForm({ id, type }: { id: string; type: string }) {
  return (
    <Form method="post" action="/forms/like">
      <input type="hidden" name={type} value={id} />{" "}
      <button type="submit">Like</button>
    </Form>
  );
}

In your Resource Route:

import { ActionFunction } from "@remix-run/node";

export let action: ActionFunction = async ({ request }) => {
 // do something here
 return ?
};

But what to return from there? Because Form navigates to the action defined, it'll take the user to /forms/like.

So you'll need to [return a redirect to the original URL](return a redirect to the original URL) if you need to pass data from the action to the user, let's say, a validation error. You would have to first store it in the session.

import { ActionFunction } from "@remix-run/node";
import { redirectBack } from "remix-utils";
import { getSession, commitSession } from "~/services/session.server";

export let action: ActionFunction = async ({ request }) => {
  // do something here
  let session = await getSession(request.headers.get("Cookie"));
  session.flash("error", "Validation error");
  return redirectBack(request, {
    fallback: "/something/here",
    headers: { "Set-Cookie": await commitSession(session) },
  });
};

Then, in your original route (the one rendering the form) loader, you need to read that session data.

import { LoaderFunction } from "@remix-run/node";
import { redirectBack } from "remix-utils";
import { getSession, commitSession } from "~/services/session.server";

export let loader: LoaderFunction = async ({ request }) => {
  // do something here
  let session = await getSession(request.headers.get("Cookie"));
  let error = session.get("error");
  return json(
    { ...data, error },
    // you should commit the session to remove the flash key
    { headers: { "Set-Cookie": await commitSession(session) } }
  );
};

This means you can't return data anymore from your actions, only redirects, which may be cumbersome. One workaround is to use the useFetcher hook to submit the form, and this will not navigate.

import { useFetcher } from "@remix-run/react";

function LikeForm({ id, type }: { id: string; type: string }) {
  let fetcher = useFetcher();
  return (
    <fetcher.Form method="post" action="/forms/like">
      <input type="hidden" name={type} value={id} />{" "}
      <button type="submit">Like</button>
    </fetcher.Form>
  );
}

The action reducer pattern

Another way to handle this is to use the action reducer pattern. What's this?

The idea is to always submit POST forms to the same route and attach a value you could use to know what to do inside the action.

Like how in useReducer, you typically pass a type to know what to do with the dispatcher object.

So know your form doesn't need to know where it will post.

import { Form } from "@remix-run/react";

function LikeForm({ id, type }: { id: string; type: string }) {
  return (
    // no action required here anymore
    <Form method="post">
      <input type="hidden" name={type} value={id} />{" "}
      <button type="submit" name="action" value="like">
        Like
      </button>
    </Form>
  );
}

As you can see, instead, we add a name and value attribute to the button. Because buttons could also have values! Another option is to render a hidden input.

<input type="hidden" name="action" value="like" />

Both options work the same way. Once we submit the form, we can use the action value to know what to do.

import { ActionFunction } from "@remix-run/node";

export let action: ActionFunction = async ({ request }) => {
  let formData = await request.formData();
  switch (formData.get("action")) {
    case "like": {
      // do something here
      return json(result);
    }
    case "another case": {
      // do something else here
      return json(result);
    }
    default: {
      throw new Error("Unknown action");
    }
  }
};

If you have familiar feelings, this is how a useReducer would look like:

import { useReducer } from "react";

function useAction() {
  return useReducer(function reducer(state, action) {
    switch (action.type) {
      case "like": {
        // do something
        return result;
      }
      case "another case": {
        // do something else
        return result;
      }
      default: {
        throw new Error("Unknown action");
      }
    }
  }, {});
}

And, of course, you may have known this pattern from Redux, which has popularized it a lot in the last years inside the React community.

The advantage of this approach is that now your loaders could return data instead of only redirects. You could use the useActionData hook from Remix to access the result of the action.

However, suppose your action has multiple cases. You may want to move any business logic outside the action to either re-use it or just change it easily. Keep the action only in charge of the HTTP layer, read from the request, call your business logic with that data and return the result as an HTTP response.

The lastest is a general good practice thing you could also do in loaders, and also if you go with the resource routes approach.