React Router Action Validation
How do you validate forms in React Router’s framework mode — the Remix lineage — so that a plain <Form method="post"> works before hydration, the route action returns field errors with a 422 status, the component renders them from useActionData(), and a fetcher can validate a single field (like a username) without navigating? This recipe builds exactly that with a shared Zod schema, then mirrors the returned errors into setCustomValidity() so the Constraint Validation API moves focus and announces them — the same behaviour as the site’s canonical novalidate plus reportValidity() baseline.
When to Use Route Actions for Validation
Route actions are the default mutation mechanism in React Router 7 framework mode (and in Remix v2 before it), so any form in such an app should validate there. They suit:
- Pages that own their mutations, where the route module holds the loader, the action and the UI together.
- Progressive enhancement, because
<Form>renders a real<form>and the action is a normal POST handler. - Per-field server checks, via
useFetcher, which submits to an action without a navigation.
For apps in data or declarative mode without server rendering, actions run in the browser and cannot provide server authority; pair them with a real API and its error contract. The cross-framework model is in server actions and progressive enhancement.
Minimal Working Route Action and Form
// app/routes/signup.tsx
import { Form, data, redirect, useActionData, useNavigation } from "react-router";
import { useEffect, useRef } from "react";
import type { Route } from "./+types/signup";
import { z } from "zod";
const schema = z.object({
username: z.string().trim().min(3, "Username must be at least 3 characters.").max(30, "Username must be 30 characters or fewer."),
email: z.string().trim().email("Enter an email address like name@example.com."),
password: z.string().min(12, "Password must be at least 12 characters."),
});
type Errors = Partial<Record<keyof z.input<typeof schema>, string>>;
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const values = { username: String(formData.get("username") ?? ""), email: String(formData.get("email") ?? "") };
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
const errors: Errors = {};
for (const i of parsed.error.issues) errors[i.path[0] as keyof Errors] ??= i.message;
return data({ errors, values }, { status: 422 });
}
if (await usernameTaken(parsed.data.username)) {
return data({ errors: { username: "That username is taken." } as Errors, values }, { status: 422 });
}
await createUser(parsed.data);
return redirect("/welcome");
}
export default function Signup() {
const result = useActionData<typeof action>();
const navigation = useNavigation();
const formRef = useRef<HTMLFormElement>(null);
const errors: Errors = result?.errors ?? {};
useEffect(() => {
const form = formRef.current;
if (!form) return;
for (const el of form.querySelectorAll<HTMLInputElement>("input[name]")) {
el.setCustomValidity(errors[el.name as keyof Errors] ?? "");
}
if (Object.keys(errors).length) form.reportValidity();
}, [result]);
return (
<Form method="post" ref={formRef} noValidate aria-busy={navigation.state === "submitting"}
onSubmit={(e) => { if (!e.currentTarget.reportValidity()) e.preventDefault(); }}>
{(["username", "email", "password"] as const).map((name) => (
<div key={name} className="field">
<label htmlFor={name}>{name[0].toUpperCase() + name.slice(1)}</label>
<input id={name} name={name} type={name === "password" ? "password" : name === "email" ? "email" : "text"}
required defaultValue={name === "password" ? undefined : result?.values?.[name]}
aria-invalid={Boolean(errors[name])} aria-describedby={errors[name] ? `${name}-err` : undefined}
onInput={(e) => e.currentTarget.setCustomValidity("")} />
{errors[name] && <p id={`${name}-err`} className="field-error">{errors[name]}</p>}
</div>
))}
<button type="submit">{navigation.state === "submitting" ? "Creating…" : "Create account"}</button>
</Form>
);
}
Returning data(..., { status: 422 }) rather than a plain object matters for more than tidiness: the status tells React Router not to revalidate loaders as if the mutation had succeeded, and it gives the no-script path a correct HTTP response. The onSubmit handler runs the native reportValidity() before React Router submits, so required and type="email" give instant feedback once hydrated, without waiting for the server.
Per-Field Server Validation With a Fetcher
Uniqueness checks like “username taken” are better surfaced while the user types than only on submit. A fetcher can call a dedicated resource route without navigating, and its result feeds the same custom-validity mechanism.
// app/routes/api.username.tsx — resource route (no default export)
export async function loader({ request }: Route.LoaderArgs) {
const name = new URL(request.url).searchParams.get("u")?.trim().toLowerCase() ?? "";
return { available: name.length >= 3 && !(await usernameTaken(name)) };
}
// inside Signup()
const check = useFetcher<typeof import("./api.username").loader>();
const timer = useRef<number>();
function onUsernameInput(e: React.FormEvent<HTMLInputElement>) {
const input = e.currentTarget;
input.setCustomValidity("");
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => check.load(`/api/username?u=${encodeURIComponent(input.value)}`), 400);
}
useEffect(() => {
const input = formRef.current?.elements.namedItem("username") as HTMLInputElement | null;
if (input && check.data && !check.data.available) input.setCustomValidity("That username is taken.");
}, [check.data]);
Fetchers automatically cancel superseded loads, which gives you the stale-response protection that hand-written code needs an AbortController for — the concern covered in cancelling stale requests with AbortController. The final submission’s action still checks uniqueness, because the fetcher’s answer can be stale by the time the user submits.
Route Action Option Reference
| Item | Type | Purpose | Notes |
|---|---|---|---|
action({ request }) |
route export | Handles POST to the route | Runs on the server in framework mode |
data(value, { status }) |
helper | Return data with an HTTP status | Use 422 for validation failures |
redirect(url) |
helper | Navigate after success | Post/redirect/get |
useActionData<typeof action>() |
hook | Typed action result | undefined before any submission |
useNavigation().state |
"idle" | "submitting" | "loading" |
Pending UI | Drive aria-busy and button text |
useFetcher() |
hook | Non-navigating loads and submits | Per-field checks, inline edits |
<Form method="post"> |
component | Enhanced native form | Falls back to a real post |
Verification Steps
import { test, expect } from "@playwright/test";
test("route action returns 422 with field errors", async ({ request }) => {
const res = await request.post("/signup", { form: { username: "ab", email: "x", password: "short" } });
expect(res.status()).toBe(422);
});
Edge Cases and Failure Modes
Returning errors with status 200. A plain object return has status 200, which React Router treats as a successful mutation and revalidates every loader on the page. That is wasted work, and on the no-script path the browser shows an error page with a success status. Always use data(..., { status: 422 }).
Using useActionData for fetcher results. Fetcher submissions do not populate useActionData; their results are on fetcher.data. Mixing them up makes per-field errors silently disappear.
Stale action data after navigation. useActionData is cleared when the route reloads after a redirect, but not when the user stays on the page and edits. Clear per-field custom validity on input, as the example does, rather than expecting the data to update.
Echoing passwords. The values object must never contain the password; browsers ignore value on password inputs anyway, but returning it puts the secret into the HTML response on the no-script path.
Setting the Document Title on Error Responses
On the no-script path, a 422 response is a whole new page, and screen reader users hear its title first. The route’s meta export only sees loader data, not the action’s result, so render the title from the component instead — React 19 hoists a <title> element rendered anywhere into the document head. Prefixing it with the error count tells users immediately that the submission did not go through, before they reach the error summary.
// inside Signup(), before the <Form>
const count = Object.keys(errors).length;
<title>{count ? `Error: ${count} problem${count > 1 ? "s" : ""} — Sign up` : "Sign up"}</title>
Nested Routes and Multiple Forms on One Page
React Router pages often contain several forms — a main form plus an inline “add tag” or “apply coupon” form — and each posts to an action. Distinguish them with an intent field, so one action can validate the right subset of fields, or point secondary forms at their own resource routes through fetchers so their errors never mix with the main form’s. With intents, return errors under the intent’s key so the UI knows which form they belong to.
export async function action({ request }: Route.ActionArgs) {
const fd = await request.formData();
switch (fd.get("intent")) {
case "apply-coupon": {
const code = String(fd.get("code") ?? "").trim();
if (!(await couponValid(code))) return data({ intent: "apply-coupon", errors: { code: "That code isn't valid." } }, { status: 422 });
return data({ intent: "apply-coupon", ok: true });
}
default:
return placeOrder(fd); // full checkout validation
}
}
A fetcher-driven coupon form is usually the better choice, because applying a coupon should not navigate, and its pending and error states stay independent of the main checkout form. The broader patterns for independent sub-forms are covered in validating accordion form sections.
Frequently Asked Questions
How do I return validation errors from a React Router action?
Return data({ errors, values }, { status: 422 }) from the route action, then read the result in the component with useActionData() and render each error next to its field.
Does a React Router Form work without JavaScript?
Yes. In framework mode the Form component renders a real form element that posts to the route action, so validation and error rendering work before hydration.
How do I validate one field on the server while the user types?
Use useFetcher to load a resource route with the field's value after a debounce, and set the field's custom validity from fetcher.data. Re-check in the action on submit, because the answer can change.
Why return status 422 instead of 200 with errors?
A 200 tells React Router the mutation succeeded, so it revalidates loaders unnecessarily, and it gives no-script clients the wrong status. 422 signals a validation failure correctly.
Related Guides
- Server Actions and Progressive Enhancement — the cross-framework model.
- Next.js Server Actions with useActionState — the same pattern in Next.js.
- React Hook Form Async Field Validation — client-library async checks for comparison.
- Username Validation Rules and Reserved Names — the rules behind the username field.