React Router 12 ⚛️ Form Actions — Handling mutations with actions
The previous chapter covered the data loaders, which fetch the data before the route renders. This chapter covers the counterpart: the form actions, which handle the data mutations after the user submits a form. The loader is the read; the action is the write. In the React Router’s Data mode, the action function is defined on the route object, and the <Form> component submits to it without a full page reload. The LFCS exam does not test React Router, but the front-end engineering certification paths do. The critical distinction is between the client-side onSubmit handler with the event.preventDefault() and the router-level <Form action>. The onSubmit handler manages the fetch and the state manually. The <Form action> submits to the route’s action, which runs on the server or the client, and the router handles the navigation and the revalidation. The action is the declarative form submission; the onSubmit is the imperative one.
Key point: The action function is defined on the route object. It receives a context object with request, params, and context. The request.formData() returns the submitted form data. The action returns the data (or a redirect) or throws an error. The <Form method="post"> submits to the action of the nearest route. The useActionData hook returns the action result. The useNavigation hook returns the submission state. The redirect returns the navigation to the new route.
Why form actions require a specific mental model
The manual submission problem. The traditional React form uses the onSubmit handler: event.preventDefault(), read the form values, fetch(...), handle the response, update the state. The code repeats for every form. The loading state, the error state, and the success state are all managed manually. The action pattern is the same logic, but the router provides the submission state and the error handling. The <Form> submits to the action, and the router tracks the idle, submitting, and loading states.
The form data problem. The action receives the request. The request.formData() returns the FormData object, which has the get and getAll methods. The Object.fromEntries(formData) converts the FormData to a plain object. The action parses the values, validates them, and performs the mutation. The FormData is the standard interface for the form submission, which means the action can be the server-side code in the Remix or the client-side code in the React Router.
The validation problem. The action is the server-side (or the client-side) validation. The invalid submission returns the errors. The useActionData hook reads the returned errors. The form re-renders with the errors. The action does not throw for the validation errors; it returns them. The throw is for the unexpected errors (the network failure, the missing resource). The useActionData is the validation feedback; the errorElement is the unexpected failure.
The redirect problem. The successful action redirects to the new page. The redirect("/path") returns a Response that the router follows. The action does not render the component; the redirect changes the route. The redirect is the post-submit navigation. The useNavigate in the component is the alternative, but the redirect in the action is the declarative form.
The revalidation problem. The loader that fetched the list re-runs after the action completes. The action adds a new item, and the loader fetches the updated list. The router automatically revalidates the loaders after the action. The useRevalidator hook is the manual trigger for the re-run. The automatic revalidation is the feature; the manual is the exception.
The trade-off. The <Form action> is the declarative form submission. The useSubmit hook is the imperative form submission from the event handler. The <Form> is the correct choice for the standard forms. The useSubmit is the correct choice for the custom controls (e.g., the button that submits a hidden form). The two are complementary.
a. The basic action and the <Form>
The action is the route property. The <Form method="post"> submits to it.
import { createBrowserRouter, Form, useActionData, redirect } from "react-router";
async function createTodoAction({ request }) {
const formData = await request.formData();
const title = formData.get("title");
if (!title || title.trim() === "") {
return { error: "Title is required" };
}
await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ title }),
headers: { "Content-Type": "application/json" },
});
return redirect("/todos");
}
function NewTodo() {
const actionData = useActionData();
return (
<Form method="post">
<input type="text" name="title" />
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit">Create</button>
</Form>
);
}
const router = createBrowserRouter([
{
path: "/todos/new",
Component: NewTodo,
action: createTodoAction,
},
]);
The <Form method="post"> submits to the /todos/new action. The request.formData() returns the title field. The empty title returns the error object. The useActionData reads the error and renders it. The successful submit returns the redirect("/todos"), which navigates to the list.
The <Form> is imported from react-router. It is not the native <form>. The <Form> component uses the router’s navigation instead of the browser’s full-page submission.
b. The useNavigation and the submission state
The useNavigation hook returns the current navigation state, which includes the form submission.
import { Form, useNavigation } from "react-router";
function NewTodo() {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<input type="text" name="title" disabled={isSubmitting} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create"}
</button>
</Form>
);
}
The navigation.state is "idle", "submitting", or "loading". The "submitting" is the action in progress. The "loading" is the loader in progress after the action. The isSubmitting disables the form during the submission, which prevents the double submit.
The navigation.formData is the submitted form data. The navigation.formAction is the action URL. The navigation.formMethod is the method. These are useful for the optimistic UI and the progress indicators.
c. The redirect and the revalidation
The action redirects on success. The loader re-runs automatically after the action.
import { redirect } from "react-router";
async function createTodoAction({ request }) {
const formData = await request.formData();
await fetch("/api/todos", {
method: "POST",
body: formData,
});
return redirect("/todos");
}
async function todosLoader() {
const res = await fetch("/api/todos");
return res.json();
}
const router = createBrowserRouter([
{
path: "/todos",
Component: TodosList,
loader: todosLoader,
action: createTodoAction,
},
]);
The action adds the todo. The redirect navigates to /todos. The router re-runs the todosLoader because the action belongs to the same route. The list shows the new todo.
The useRevalidator hook is the manual trigger.
import { useRevalidator } from "react-router";
function RefreshButton() {
const revalidator = useRevalidator();
return (
<button onClick={() => revalidator.revalidate()}>Refresh</button>
);
}
The revalidator.revalidate() re-runs the loaders for the current route. The state is "loading" during the revalidation. The button is useful for the manual refresh after the external change.
Complete Example Session
# ============================================
# PART 1: DEFINE THE ACTION
# ============================================
async function action({ request }) {
const formData = await request.formData();
const title = formData.get("title");
if (!title) return { error: "Required" };
await save({ title });
return redirect("/todos");
}
# ============================================
# PART 2: THE FORM
# ============================================
<Form method="post">
<input name="title" />
<button>Create</button>
</Form>
# ============================================
# PART 3: READ ERRORS
# ============================================
const data = useActionData();
{data?.error && <p>{data.error}</p>}
# ============================================
# PART 4: SUBMISSION STATE
# ============================================
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
# ============================================
# PART 5: DISABLE THE FORM
# ============================================
<input name="title" disabled={isSubmitting} />
# ============================================
# PART 6: REDIRECT ON SUCCESS
# ============================================
return redirect("/todos");
# ============================================
# PART 7: LOADER REVALIDATION
# ============================================
// The loader re-runs automatically after the action.
# ============================================
# PART 8: MANUAL REVALIDATION
# ============================================
const revalidator = useRevalidator();
revalidator.revalidate();
# ============================================
# PART 9: VERIFY
# ============================================
# Submit empty → error shown
# Submit valid → redirect to /todos
# List updated with the new todo
The nine parts trace the form action: action, form, error read, submission state, disable, redirect, revalidation, manual revalidation, and verification.
Quick Reference
Route Properties
| Property | Purpose |
|---|---|
action | Mutation handler |
loader | Data fetch |
Component | Route component |
Action Context
| Property | Purpose |
|---|---|
request.formData() | Submitted data |
params | Route params |
context | Router context |
Hooks
| Hook | Returns |
|---|---|
useActionData() | Action return value |
useNavigation() | Navigation state |
useSubmit() | Imperative submit |
useRevalidator() | Manual revalidate |
Navigation State
| State | Meaning |
|---|---|
idle | No navigation |
submitting | Action in progress |
loading | Loader in progress |
Best Practices
✅ Do This:
<Form method="post">
<!-- Use the Form component --> # ✅
if (!title) return { error: "Required" };
<!-- Return the validation errors --> # ✅
return redirect("/todos");
<!-- Redirect after the successful mutation --> # ✅
❌ Don’t Do This:
# Use the native form
<!-- Full page reload --> # ❌
# Throw for the validation errors
<!-- Return them; useActionData reads them --> # ❌
# Forget the submission state
<!-- The user can submit twice --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Full page reload | Used <form> | Use <Form> |
| Errors not showing | Used throw for validation | Return the errors |
| Double submit | No isSubmitting | Disable the form |
| List not updating | Loader not attached | Attach the loader to the same route |
| Redirect not working | Returned a value | Use redirect(...) |
Real-World Examples
1. Basic Action
async function action({ request }) {
const data = await request.formData();
await save(data);
return redirect("/");
}
2. The Form
<Form method="post"><input name="title" /><button>Save</button></Form>
3. Read Errors
const data = useActionData();
{data?.error && <p>{data.error}</p>}
4. Submission State
const nav = useNavigation();
const isSubmitting = nav.state === "submitting";
5. Disable Form
<input disabled={isSubmitting} />
6. Redirect
return redirect("/todos");
7. Revalidate
const r = useRevalidator();
r.revalidate();
8. Form Data
const formData = await request.formData();
const title = formData.get("title");
9. Multiple Actions
const router = createBrowserRouter([
{ path: "/new", action: createAction },
{ path: "/edit", action: editAction },
]);
10. Delete Action
<Form method="post" action={`/todos/${id}/delete`}>
<button>Delete</button>
</Form>
Visual
Form Submission Flow
┌──────────────────────────────────────────────┐
│ User submits <Form method="post"> │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Router runs the action │
│ → request.formData() │
│ → validate │
│ → mutate │
└──────────┬───────────────────────┬───────────┘
│ SUCCESS │ VALIDATION
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ redirect("/list") │ │ return { error } │
│ Loader re-runs │ │ Form re-renders │
└──────────────────────┘ └──────────────────────┘
Submission State
┌──────────────────────────────────────────────┐
│ navigation.state = "idle" │
│ → Show the form │
└──────────────────┬───────────────────────────┘
│ user submits
▼
┌──────────────────────────────────────────────┐
│ navigation.state = "submitting" │
│ → Disable the form │
└──────────────────┬───────────────────────────┘
│ action completes
▼
┌──────────────────────────────────────────────┐
│ navigation.state = "loading" │
│ → Loader re-runs │
└──────────────────┬───────────────────────────┘
│ loader completes
▼
┌──────────────────────────────────────────────┐
│ navigation.state = "idle" │
│ → New content rendered │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Action property | action in route |
| Form component | <Form method="post"> |
| Form data | request.formData() |
| Read action result | useActionData() |
| Navigation state | useNavigation() |
| Redirect | return redirect(...) |
| Manual revalidate | useRevalidator() |
| Error for unexpected | throw new Response(...) |
Key takeaways:
- The action handles the mutation. It runs after the form submission, parses the
request.formData(), validates, mutates, and returns the result or the redirect. The<Form method="post">submits to the action of the nearest route. - The validation errors are returned, not thrown. The
return { error }is the validation response. TheuseActionDatareads it. Thethrowis for the unexpected errors, which theerrorElementcatches. - The
useNavigationprovides the submission state. The"submitting"state is the form submission in progress. The"loading"state is the loader revalidation after the action. The state is the disabling and the progress indicator. - The
redirectis the post-submit navigation. Thereturn redirect("/path")navigates to the new route and triggers the loader re-run. - The loader re-runs automatically. The action and the loader on the same route are connected. The action mutates, and the loader fetches the updated data. The
useRevalidatoris the manual trigger for the cases outside the form submission.
Remember: The action is the mutation. The <Form> is the submission. The useActionData is the result. The useNavigation is the state. The redirect is the navigation. The useRevalidator is the refresh. The exam tests the action function and the form submission state.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!