| | |

React Router 12 ⚛️ Form Actions — Handling mutations with actions

⏱️ Estimated read: 10 min | |

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

PropertyPurpose
actionMutation handler
loaderData fetch
ComponentRoute component

Action Context

PropertyPurpose
request.formData()Submitted data
paramsRoute params
contextRouter context

Hooks

HookReturns
useActionData()Action return value
useNavigation()Navigation state
useSubmit()Imperative submit
useRevalidator()Manual revalidate

Navigation State

StateMeaning
idleNo navigation
submittingAction in progress
loadingLoader 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

PitfallWhy It HappensFix
Full page reloadUsed <form>Use <Form>
Errors not showingUsed throw for validationReturn the errors
Double submitNo isSubmittingDisable the form
List not updatingLoader not attachedAttach the loader to the same route
Redirect not workingReturned a valueUse 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

ItemValue
Action propertyaction in route
Form component<Form method="post">
Form datarequest.formData()
Read action resultuseActionData()
Navigation stateuseNavigation()
Redirectreturn redirect(...)
Manual revalidateuseRevalidator()
Error for unexpectedthrow 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. The useActionData reads it. The throw is for the unexpected errors, which the errorElement catches.
  • The useNavigation provides 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 redirect is the post-submit navigation. The return 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 useRevalidator is 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.

Was this helpful?


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!