React Router 7: 'Multiple Actions' on a Single Route
How to organize multiple mutations in React Router 7 with routes and progressive forms.
- frontend
- architecture
- react-router
- web-platform
This might be the most common takeaway from people who only ever saw React Router as an SPA router:
I can only have one action per route
Comparing it to Next.js (Server Actions, API routes) reinforces the idea. A lot of folks treat loader and action like HTTP endpoints: one function, one job.
In React Router as a framework, both functions work more like a controller: several reads and mutations on the same route, each with its own purpose.
Below, three ways to do that in a user CRUD. If you're still on Remix 2 (pre-React Router 7), the reasoning is the same.
Using the Form component and default behavior
This is the most traditional approach. We use the <Form> component and tell actions apart with the method attribute.
On a route, create the loader:
export const loader = async ({ request }: LoaderFunctionArgs) => {
const users = await getUsers();
return data({ users });
};
Build components with different forms, one per method/action:
import {
Form,
/* ... */
} from "react-router";
/* ... */
export default function UI({ loaderData: { users } }: Route.ComponentProps) {
return (
<div className="container flex flex-col gap-5 mx-auto p-8">
<h1>Users CRUD (Form - default behavior)</h1>
<h2>Add User</h2>
{/* Formulário com method="post" para criar usuário */}
<Form method="post" className="flex gap-4">
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<button type="submit">Criar Usuário</button>
</Form>
<hr className="my-8" />
<h2>Users</h2>
<div className="flex flex-col gap-4">
{users.map((user: User) => (
<div key={user.id} className="py-4 gap-4 flex items-center">
{/* Formulário com method="put" para editar usuário */}
<Form
method="put"
className="py-4 gap-4 flex items-center flex-1"
data-user-id={user.id}
>
<input name="name" type="text" defaultValue={user.name} />
<input name="email" type="email" defaultValue={user.email} />
<div className="ml-auto">
<strong>Role:</strong>
</div>
<select name="role" defaultValue={user.role}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button type="submit">Save</button>
</Form>
{/* Formulário com method="delete" para excluir usuário */}
<Form method="delete">
<input type="hidden" name="id" value={user.id} />
<button type="submit" className="!bg-red-500 hover:!bg-red-600">
Delete
</button>
</Form>
</div>
))}
</div>
<hr className="my-8" />
</div>
);
}
On the same route, define the action. We use await request.formData() to read the data and request.method to distinguish POST, PUT, and DELETE.
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const method = request.method.toLowerCase();
switch (method) {
case "post": {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
return await createUser({ name, email });
}
case "put": {
const id = formData.get("id") as string;
const name = formData.get("name") as string;
const email = formData.get("email") as string;
return await updateUser(id, { name, email });
}
case "delete": {
const id = formData.get("id") as string;
return await deleteUser(id);
}
default:
throw new Response("Method not allowed", {
status: 405,
statusText: "Method Not Allowed",
});
}
};
That's it.
This approach is solid, works without JavaScript, and follows progressive enhancement.
The downside (compared to the other options) is that mutations are limited to standard HTTP methods.
With useSubmit and JSON: actions defined in the payload
Here we use the useSubmit hook to send JSON with an intent property that picks which action to run. The first win: custom mutations beyond what <Form> method gives you.
We reuse the same loader from the previous example:
export const loader = async ({ request }: LoaderFunctionArgs) => {
const users = await getUsers();
return data({ users });
};
On the route component, we define event handlers for user actions and fire the matching mutation. A concrete win: you can split editing a user's role from the other fields into its own action.
export default function UI({
loaderData: { users },
}: Route.ComponentProps) {
const submit = useSubmit();
// Event handler para criar usuário
const handleCreate = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = String(formData.get("name"));
const email = String(formData.get("email"));
const data = { intent: "createUser", payload: { name, email } };
submit(JSON.stringify(data), {
method: "post",
encType: "application/json",
});
event.currentTarget.reset();
};
// Event handler para editar usuário
const handleUpdate = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const userId = event.currentTarget.getAttribute("data-user-id");
if (userId) {
const name = String(formData.get("name"));
const email = String(formData.get("email"));
const data = {
intent: "updateUser",
payload: { id: userId, name, email },
};
submit(JSON.stringify(data), {
method: "post",
encType: "application/json",
});
}
};
// Event handler para alterar o cargo do usuário
const handleChangeRole = (event: React.ChangeEvent<HTMLSelectElement>) => {
const userId = event.target.closest("form")?.getAttribute("data-user-id");
if (userId) {
const role = event.target.value as "admin" | "user";
const data = { intent: "changeUserRole", payload: { id: userId, role } };
submit(JSON.stringify(data), {
method: "post",
encType: "application/json",
});
}
};
// Event handler para excluir usuário
const handleDelete = (userId: string) => {
if (confirm("Tem certeza que deseja deletar este usuário?")) {
const data = { intent: "deleteUser", payload: { id: userId } };
submit(JSON.stringify(data), {
method: "post",
encType: "application/json",
});
}
};
return (
<div className="container flex flex-col gap-5 mx-auto p-8">
<h1>Users CRUD (JSON API)</h1>
<h2>Add User</h2>
{/* Formulário para criar usuário */}
<form onSubmit={handleCreate} className="flex gap-4">
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<button type="submit">Criar Usuário</button>
</form>
<hr className="my-8" />
<h2>Users</h2>
<div className="flex flex-col gap-4">
{users.map((user: User) => (
{/* Formulário para editar usuário */}
<form
className="py-4 gap-4 flex items-center"
key={user.id}
onSubmit={handleUpdate}
data-user-id={user.id}
>
<input name="name" type="text" defaultValue={user.name} />
<input name="email" type="email" defaultValue={user.email} />
<button type="submit">Save</button>
<div className="ml-auto">
<strong>Role:</strong>
</div>
{/* Evento que dispara a handler de edição de cargo */}
<select defaultValue={user.role} onChange={handleChangeRole}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
{/* Evento de click para excluir usuário */}
<button type="button" className="!bg-red-500 hover:!bg-red-600" onClick={() => handleDelete(user.id)}>Delete</button>
</form>
))}
</div>
<hr className="my-8" />
</div>
);
}
On the same route, define the action. We use await request.json() and read intent from the body to route the mutation:
export const action = async ({ request }: ActionFunctionArgs) => {
const body = await request.json();
const { intent, payload } = body;
switch (intent) {
case "createUser":
return await createUser(payload);
case "updateUser":
return await updateUser(payload.id, payload);
case "deleteUser":
return await deleteUser(payload.id);
case "changeUserRole":
return await changeUserRole(payload.id, payload.role);
default:
throw new Error("Method not allowed");
}
};
This version fits more complex data shapes (nested objects, for example) because JSON is flexible.
You can use discriminated unions in TypeScript for strongly typed payloads.
The downside: it needs JavaScript and gets verbose. Especially in the examples here.
For file uploads, FormData is still the better option. Heaven forbid base64.
Actions as route params: my favorite of the three
Here we're back to the Form component. Instead of using method to distinguish actions, we use the action attribute.
Same loader as before:
export const loader = async ({ request }: LoaderFunctionArgs) => {
const users = await getUsers();
return data({ users });
};
In the UI we use Form, wiring each mutation to the action param. We also set navigate={false} on every form to avoid navigation and history changes (you'll see why in a moment):
export default function UI({ loaderData: { users } }: Route.ComponentProps) {
return (
<div className="container flex flex-col gap-5 mx-auto p-8">
<h1>Users CRUD (Actions as params)</h1>
<h2>Add User</h2>
{/* Formulário para criar usuário */}
<Form action="createUser" method="post" navigate={false} className="flex gap-4">
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<button type="submit">Criar Usuário</button>
</Form>
<hr className="my-8" />
<h2>Users</h2>
<div className="flex flex-col gap-4">
{users.map((user: User) => (
<div className="py-4 gap-4 flex items-center justify-between">
{/* Formulário para editar usuário */}
<Form
action="updateUser"
method="post"
navigate={false}
className="py-4 gap-4 flex items-center"
key={user.id}
data-user-id={user.id}
>
<input name="name" type="text" defaultValue={user.name} />
<input name="email" type="email" defaultValue={user.email} />
<button type="submit">Save</button>
</Form>
{/* Formulário para mudar o cargo */}
<Form
action="changeUserRole"
method="post"
navigate={false}
className="flex items-center gap-4"
>
<div>
<strong>Role:</strong>
</div>
<select name="role" defaultValue={user.role}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button type="submit">Save</button>
</Form>
{/* Formulário para excluir usuário */}
<Form action="deleteUser" method="post" navigate={false}>
<input type="hidden" name="id" value={user.id} />
<button type="submit" className="!bg-red-500 hover:!bg-red-600">
Delete
</button>
</Form>
</div>
))}
</div>
<hr className="my-8" />
</div>
);
}
Before we get to the action, here's what happens:
Say this route is /users. When you delete a user, the form posts to deleteUser as a child of /users: /users/deleteUser. For this pattern you need an extra route dedicated to actions.
Wait, William! You said everything would stay on the same route!
Yes, young grasshopper. The actions still live in one place.
But flexibility, a more complex UI with multiple actions (including custom ones), default form behavior, and simple, clean code don't come for free.
The price is a dynamic route segment for the actions.
For /users, that route is /users/:action, declared manually, or users.$action.tsx using File Route Conventions (same as Remix).
Here we go back to request.formData() and read the action from the route param.
export const action = async ({ request, params }: ActionFunctionArgs) => {
const formData = await request.formData();
const action = params.action;
switch (action) {
case "createUser":
const name = formData.get("name") as string;
const email = formData.get("email") as string;
return await createUser({ name, email });
case "updateUser":
const id = formData.get("id") as string;
const userName = formData.get("name") as string;
const userEmail = formData.get("email") as string;
return await updateUser(id, { name: userName, email: userEmail });
case "deleteUser":
const deleteId = formData.get("id") as string;
return await deleteUser(deleteId);
case "changeUserRole":
const roleId = formData.get("id") as string;
const role = formData.get("role") as "admin" | "user";
return await changeUserRole(roleId, role);
default:
throw new Error("Method not allowed");
}
};
Why do I prefer this approach?
It keeps the native React Router <Form> and still lets you branch on _action or intent. The route action becomes where the screen's mutations live together, instead of scattering fetch across components.
Full code on GitHub. One approach per route. Live demo to inspect in DevTools.