DEV Community

Cover image for How to handle multiple form actions in Remix
Akos
Akos

Posted on • Originally published at akoskm.com on

How to handle multiple form actions in Remix

Form actions in Remix are handled by the action function that runs on your server. They work very much like loaders, except they're called when you make a DELETE, PATCH, POST, or PUT to your route.

Remix's action/loader structure is incredibly straightforward and easy to grasp! If you've got some experience with data flow in client-server applications, you'll find this a piece of cake. But before we answer how we can handle multiple form actions in Remix, let's understand how the data flow works.

The Remix Fullstack Data Flow

In the Remix Fullstack Data Flow, the loader fetches data from the backend and passes that data to your component. The action passes your component data to the backend. After the action finishes, loaders are revalidated and return the current state of the backend to the component.

Remix Fullstack Data Flow

This is how Remix keeps your backend and frontend data in sync without any additional code to do refetches.

Also, in Remix each route has one loader and one action.

The issue arises when, on the client, you want to handle different functionalities, and each functionality would require a different action on the backend.

Let's take a look at this simple Todo app:

export async function loader() {
  return json({ todos: getTodos() });
}

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const { todo } = Object.fromEntries(formData.entries());
  await addTodo(todo);
  return json({ ok: true });
}

export default function Index() {
  const { todos } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();

  return (
    <div className="max-w-md">
      <h1 className="text-2xl pb-10">Todos</h1>
      <Form method="post" className="flex flex-col gap-4">
        <label htmlFor="todo">Title</label>
        <input type="text" name="todo" id="todo" autoComplete="off" />
        <button>Add</button>
      </Form>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id} className="text-lg pt-5">
             {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

There is nothing fancy here, just an input and a button to submit a todo item to your list.

The problem with multiple actions

Let's say we want to extend the functionality with a button to remove all items with a single click:

<Form method="post" className="flex flex-col gap-4">
  <label htmlFor="todo">Title</label>
  <input type="text" name="todo" id="todo" autoComplete="off" />
  <button>Add</button>
  <button>Clear</button>
</Form>

Enter fullscreen mode Exit fullscreen mode

But because we have only one action handler, it doesn't matter which of these buttons we click, the action result is the same:

Delete Todos with incorrect behavior

The solution

To work around this, we'll add two new attributes to each of these buttons: name and value.

I like to use name="intent" (reminds me of Android's Intent) and a custom value describing the action we want to run:

<Form method="post" className="flex flex-col gap-4">
  <label htmlFor="todo">Title</label>
  <input type="text" name="todo" id="todo" autoComplete="off" />
  <button name="intent" value="add">Add</button>
  <button name="intent" value="clear">Clear</button>
</Form>

Enter fullscreen mode Exit fullscreen mode

Now, whenever we submit our form data, the request's request.formData will contain an additional field formData.get("intent").

We can use this value to decide what to do next:

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const { todo } = Object.fromEntries(formData.entries());
  const intent = formData.get("intent");
  switch (intent) {
    case "add": await addTodo(todo); break;
    case "clear": clearTodos(); break;
    default: throw new Error("Unexpected action");
  }
  return json({ ok: true });
}

Enter fullscreen mode Exit fullscreen mode

And finally, here's how the UI works after taking into consideration the value of intent:

Delete Todos, fixed behavior

You can experiment with the above pattern in the Codesandbox I created.

Further reading

Remix Tutorial - I highly recommend you do the ~30-minute long, comprehensive, official tutorial explaining basic Remix concepts such as data mutations, data loading, routing, styling helpers, etc.

Community - check out the Remix community links, including their Discord server and a vast collection of Remix-related links.

The Ultimate Full Stack Framework for 2024: Remix - Remix blew my mind with its simplicity and philosophy. After using it only for a few days, I wrote about why it is my big bet for 2024.

If you enjoyed this article, please like it and share it with developers who would find it useful.

Thanks,
Akos

Top comments (0)