React Router’s framework mode (and Remix before it) makes every <Form> post to a route action, which is elegant until validation fails: returning a plain object with status 200 makes the router revalidate every loader for nothing, actionData vanishes on the next navigation, and a fetcher-based inline edit shows errors in the wrong place.

This page covers route actions as the validation layer for server-rendered React apps: returning errors with a 400, reading them with useActionData, pending UI through useNavigation, and useFetcher for forms that should not navigate. It sits alongside returning validation errors from Next.js server actions within hydration sync for SSR forms.


Context and prerequisites

How React Router’s data APIs handle a form:

  • <Form method="post"> submits to the current route’s action (or the one named by action=). Without JavaScript it is a normal HTML form post; with it, the router intercepts and uses fetch.
  • action({ request }) reads await request.formData(), validates, and either returns data (to re-render the route with actionData) or returns/throws a redirect.
  • Revalidation — after an action, the router re-runs the loaders on the page so data reflects the mutation. By default a 4xx/5xx action response skips revalidation; a 200 does not.
  • useNavigation() exposes state (idle / submitting / loading) and formData for pending UI.
  • useFetcher() submits to an action without navigating, with its own data and state — ideal for inline edits, row-level saves and search-as-you-type.
A failed validation in a route action The component's Form posts to the route action. The router shows the submitting state through useNavigation. The action validates the form data, finds errors, and returns data with the field errors and submitted values and status 400. Because the status is 400, the router skips revalidating loaders. The component re-renders with useActionData containing the errors and values, and navigation returns to idle. Form component Router Route action <Form method="post"> submit navigation.state = "submitting" action({ request }) data({ errors, values }, { status: 400 }) actionData; loaders NOT revalidated

The core pattern: action, component and fetcher

// app/routes/settings.profile.tsx (React Router v7 framework mode)
import { data, redirect, Form, useActionData, useNavigation, useLoaderData } from "react-router";
import { z } from "zod";
import type { Route } from "./+types/settings.profile";

const Profile = z.object({
  displayName: z.string().trim().min(2, "Use at least 2 characters."),
  bio: z.string().trim().max(280, "Keep your bio to 280 characters or fewer."),
});

export async function loader({ request }: Route.LoaderArgs) {
  return { profile: await getProfile(request) };
}

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const values = { displayName: String(form.get("displayName") ?? ""), bio: String(form.get("bio") ?? "") };
  const parsed = Profile.safeParse(values);
  if (!parsed.success) {
    // 400: tells the router not to revalidate loaders for a failed mutation.
    return data({ errors: parsed.error.flatten().fieldErrors, values }, { status: 400 });
  }
  await saveProfile(request, parsed.data);
  return redirect("/settings/profile?saved=1");   // POST/redirect/GET
}

export default function ProfileSettings() {
  const { profile } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const submitting = navigation.state === "submitting" && navigation.formAction?.endsWith("/settings/profile");
  const errors = actionData?.errors ?? {};
  const values = actionData?.values ?? profile;

  return (
    <Form method="post" noValidate>
      <label htmlFor="displayName">Display name</label>
      <input id="displayName" name="displayName" defaultValue={values.displayName}
        aria-invalid={errors.displayName ? true : undefined}
        aria-describedby={errors.displayName ? "displayName-error" : undefined} />
      {errors.displayName && <p id="displayName-error">{errors.displayName[0]}</p>}
      <label htmlFor="bio">Bio</label>
      <textarea id="bio" name="bio" defaultValue={values.bio} aria-invalid={errors.bio ? true : undefined}
        aria-describedby={errors.bio ? "bio-error" : undefined} />
      {errors.bio && <p id="bio-error">{errors.bio[0]}</p>}
      <button type="submit" aria-disabled={submitting}>{submitting ? "Saving…" : "Save"}</button>
    </Form>
  );
}
declare function getProfile(r: Request): Promise<{ displayName: string; bio: string }>;
declare function saveProfile(r: Request, d: unknown): Promise<void>;
// Inline edit that should not navigate: a fetcher per row.
function RenameRow({ item }: { item: { id: string; name: string } }) {
  const fetcher = useFetcher<typeof action>();
  const error = fetcher.data?.errors?.displayName?.[0];
  return (
    <fetcher.Form method="post" action={`/items/${item.id}/rename`}>
      <label htmlFor={`name-${item.id}`}>Name</label>
      <input id={`name-${item.id}`} name="displayName" defaultValue={item.name} aria-invalid={error ? true : undefined} />
      {error && <p role="alert">{error}</p>}
      <button aria-disabled={fetcher.state !== "idle"}>Rename</button>
    </fetcher.Form>
  );
}

Step-by-step walkthrough

  1. Validate in the action with a schema. The action runs on the server for native posts and fetch submissions alike.
  2. Return data(…, { status: 400 }) on failure. The status keeps the router from re-running loaders for a mutation that did not happen, and it is the correct HTTP semantics for the no-JS path.
  3. Return the submitted values. Use them as defaultValues so a failed submit keeps the user’s input on both paths.
  4. Redirect on success. POST/redirect/GET prevents duplicate submissions on reload and triggers the loaders that should reflect the change.
  5. Derive pending UI from useNavigation. Check navigation.formAction so only the submitting form shows a pending state when a page has several.
  6. Use useFetcher for non-navigating forms. Each fetcher has its own data, so errors appear next to the row that produced them rather than in page-level actionData.

Why the status code changes behaviour

React Router treats an action’s response status as a signal about whether data changed. A 2xx from an action means “a mutation happened”, so every loader on the page is re-run to pick up new data — for a failed validation, that is wasted server work and can reset other state on the page. A 4xx means “nothing changed”, so revalidation is skipped. Returning validation errors with status 400 is therefore not just correct HTTP; it is how you tell the router to leave the rest of the page alone. You can fine-tune the decision per route with shouldRevalidate, but getting the status right removes most of the need.

Action results and what the router does next Returning data with status 400 re-renders the route with actionData and skips loader revalidation; the user stays on the form with errors. Returning data with status 200 re-renders with actionData and revalidates all loaders; use it for success messages that stay on the page. Returning a redirect navigates to the target and runs its loaders, the usual success path. Throwing a response renders the nearest error boundary and should be reserved for unexpected failures, not validation. Action returns Router does Use for data(…, { status: 400 }) actionData; skip revalidation validation errors data(…) (200) actionData; revalidate loaders success message in place redirect(url) navigate; run target loaders normal success throw new Response(…) error boundary unexpected failures only

Failure modes and edge cases

1. actionData disappears

actionData belongs to the navigation that produced it; after the next navigation it is gone. That is correct for errors. For persistent messages (“Saved”), redirect with a query parameter or a flash session value instead of relying on actionData.

2. Multiple forms, one action

If a route hosts several forms posting to one action, include an intent field (<button name="intent" value="rename">) and branch in the action; return errors namespaced by intent so each form renders only its own.

3. Aborted submissions

Submitting again while a submission is in flight cancels the first request on the client (the router aborts it), but the server may still process it. Make actions idempotent where duplicates matter, as in handling double submit and idempotency.

4. Focus after errors

The route re-renders in place after a failed submit; nothing moves focus. Add an effect keyed on actionData that focuses the error summary or first invalid field, as in moving focus to the first invalid field.

5. Optimistic UI with fetchers

fetcher.formData exposes the values being submitted, which lets you render the new state immediately. If the action returns errors, the optimistic state disappears when fetcher.formData clears; show the error and the old value, following rolling back optimistic updates on failure.

Form, fetcher, or submit()? Use the Form component for page-level forms whose success should navigate, such as settings pages and signups; errors come back as actionData. Use useFetcher for inline or repeated forms that should not navigate, such as row renames and toggles; each fetcher has its own data and state. Use useSubmit for programmatic submissions such as autosave, while keeping a real form for no-JavaScript users. <Form> Page-level forms. Navigate on success. Errors in actionData. useFetcher Inline and per-row forms. No navigation. Errors in fetcher.data. useSubmit Programmatic (autosave). Keep a real form underneath.

Verification checklist


Frequently Asked Questions

Is this the same in Remix?

Yes in substance. Remix v2’s json() helper became data() in React Router v7, and imports moved from @remix-run/* to react-router, but actions, useActionData, useNavigation and fetchers work the same way.

Can I use client-side validation too?

Yes. Validate on blur with the same schema for instant feedback, and let the action be the authority. With clientAction you can also validate before the request leaves the browser, returning errors without a round trip — but keep the server action’s validation regardless.

How do I show a success message without navigating?

Return data({ ok: true }) with status 200 and render a role="status" message from actionData, accepting that loaders revalidate. Or use a fetcher, whose data holds the success result without navigation.


Related

← Hydration Sync for SSR Forms