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’saction(or the one named byaction=). Without JavaScript it is a normal HTML form post; with it, the router intercepts and usesfetch.action({ request })readsawait request.formData(), validates, and either returns data (to re-render the route withactionData) or returns/throws aredirect.- 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()exposesstate(idle/submitting/loading) andformDatafor pending UI.useFetcher()submits to an action without navigating, with its owndataandstate— ideal for inline edits, row-level saves and search-as-you-type.
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
- Validate in the action with a schema. The action runs on the server for native posts and fetch submissions alike.
- 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. - Return the submitted values. Use them as
defaultValues so a failed submit keeps the user’s input on both paths. - Redirect on success. POST/redirect/GET prevents duplicate submissions on reload and triggers the loaders that should reflect the change.
- Derive pending UI from
useNavigation. Checknavigation.formActionso only the submitting form shows a pending state when a page has several. - Use
useFetcherfor non-navigating forms. Each fetcher has its owndata, so errors appear next to the row that produced them rather than in page-levelactionData.
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.
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.
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.