Server-first data and mutation patterns for React Router v7. React Router v7 combines client-side routing with server-side data loading and mutation handling — eliminating the need for SPA-era client-side data layers.
For general React patterns (hooks, state, composition), see /react. For TypeScript language patterns and build tooling, see /typescript. For component design and accessibility, see /ux-design.
1. Route Loaders & Type Safety
Loaders are the data cache
Loaders run on the server before render. They are the single source of server data — no SPA-era client-side caching layer is needed on top.
export async function loader({ request, params }: LoaderFunctionArgs) {
const id = params.id;
if (!id) throw new Response("Not Found", { status: 404 });
const resource = await api.getResource(id);
return { resource };
}
export default function ResourcePage() {
const { resource } = useLoaderData<typeof loader>();
return <ResourceDetail resource={resource} />;
}
Loader type safety
Type loaders with framework-provided generics. useLoaderData<typeof loader>() infers the return type automatically:
// Typed loader — useLoaderData infers the return type
export async function loader({ request, params }: LoaderFunctionArgs) {
const resources = await apiClient.getResources();
return { resources }; // useLoaderData<typeof loader> infers { resources: Resource[] }
}
Key rules
- Loaders are the cache — data is available when the page renders, no loading spinners for initial data
- Never copy server data into
useState—useLoaderDataowns it - Loaders auto-rerun after successful actions — no manual cache invalidation
- Use
parsePaginationParams()or similar helpers for URL-driven data (filters, sort, pagination) - Throw
Responseon error to trigger the route'sErrorBoundary - Return plain objects for success — access via
useLoaderData<typeof loader>()
2. Route Actions & Mutations
Form for all mutations
All mutations flow through route actions via <Form method="post"> — never onClick + fetch():
<Form method="post">
<input type="hidden" name="_intent" value="create" />
<input name="name" defaultValue="" required />
<button type="submit">Create</button>
</Form>
Form is from react-router, not HTML — it handles serialization and triggers loader revalidation after the action completes.
Intent pattern for multi-action routes
Multi-action routes use a hidden _intent field to discriminate:
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = String(formData.get("_intent") ?? "create");
if (intent === "delete") {
const id = String(formData.get("id") ?? "");
if (!id)
return Response.json(
{ error: "Missing id", intent: "delete" },
{ status: 400 },
);
try {
await api.deleteResource(id);
return Response.json({ success: true, intent: "delete" });
} catch (error) {
const status = extractErrorStatus(error);
return Response.json(
{ error: "Failed to delete", intent: "delete" },
{ status },
);
}
}
// handle create, update...
}
Action return shape & type safety
type ActionResult = { intent: string; error?: string; success?: boolean };
- Always include
intentso the UI can scope error display to the correct form - Use
Response.json()for both success and error — never throw for expected errors - Access via
useActionData<typeof action>()— validate with a type guard since it returnsunknown
function isActionResult(data: unknown): data is ActionResult {
if (typeof data !== "object" || data === null) {
return false;
}
const d = data as Record<string, unknown>;
if (typeof d.intent !== "string") {
return false;
}
if ("error" in d && typeof d.error !== "string") {
return false;
}
if ("success" in d && typeof d.success !== "boolean") {
return false;
}
return true;
}
Form inputs
- Use
defaultValuefor form fields (uncontrolled inputs) — the browser manages form state - Never use
useState+valuefor fields that will be submitted via<Form> - Extract FormData parsing into named functions to keep actions focused
function parseFormData(formData: FormData) {
return {
name: String(formData.get("name") ?? ""),
value: String(formData.get("value") ?? ""),
kind: String(formData.get("kind") ?? ""),
};
}
Submission state
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</button>;
For fetcher-driven mutations, use fetcher.state instead.
3. Non-Navigation Mutations with useFetcher
When to use fetcher
Use useFetcher when the mutation should not trigger a full-page navigation:
const fetcher = useFetcher();
<fetcher.Form method="post">
<input type="hidden" name="_intent" value="toggle" />
<button type="submit">Toggle</button>
</fetcher.Form>;
Use cases: inline toggles, background saves, actions in list items that should not scroll to top.
Optimistic UI via fetcher.formData
Derive optimistic state from fetcher.formData — the pending submission data:
const fetcher = useFetcher();
return (
<>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
{fetcher.formData && (
<li className="opacity-50">
{String(fetcher.formData.get("name") ?? "")}
</li>
)}
</ul>
<fetcher.Form method="post">
<input type="hidden" name="_intent" value="create" />
<input name="name" required />
<button type="submit">Add</button>
</fetcher.Form>
</>
);
fetcher.formData is non-null while the submission is in flight. When the action completes, loaders revalidate, the real item (with its server-assigned ID) appears in items, and fetcher.formData resets to null — the optimistic element disappears automatically. For multiple concurrent submissions, use useFetchers() to render all pending items.
4. Route Error Boundaries
Layered error boundaries
- Root boundary: Catches catastrophic errors, shows a full-page error screen
- Route boundary: Catches loader/action errors per route (framework-provided
ErrorBoundaryexport) - Feature boundary: Wraps individual widgets so a single failure doesn't take down the page
import { ErrorBoundary } from "react-error-boundary";
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => {
/* revalidate loaders or navigate to same route */
}}
resetKeys={[resourceId]}
>
<ResourceDetail />
</ErrorBoundary>;
Route-level error handling
React Router automatically catches loader and action errors:
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status}</h1>
<p>{error.statusText}</p>
</div>
);
}
return <h1>Unexpected Error</h1>;
}
What error boundaries do NOT catch
- Errors in event handlers (use try/catch)
- Async errors outside React rendering (handle in promise chains)
- Errors in the error boundary itself
Retry pattern
Offer a "Try again" button that calls resetErrorBoundary(). For route-level errors, a page reload retriggers the loader.
5. URL State Management
useSearchParams for filters, sort, pagination
URL state is the most underused state location — filters, sort order, and pagination belong in the URL:
const [searchParams, setSearchParams] = useSearchParams();
const sort = searchParams.get("sort") ?? "name";
const page = Number(searchParams.get("page") ?? "1");
const setSort = (value: string) => {
setSearchParams((prev) => {
prev.set("sort", value);
prev.delete("page"); // reset to page 1
return prev;
});
};
Benefits:
- URLs are bookmarkable and shareable
- Browser back/forward work automatically
- State persists across page reloads
- No state synchronization code needed
URL-driven loader data
Loaders receive the URL as part of the request and can parse it for data:
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "1");
const sort = url.searchParams.get("sort") ?? "name";
const resources = await api.getResources({ page, sort });
return { resources, page, sort };
}
6. API Client Patterns in Loaders & Actions
Generated clients from OpenAPI
Use generated clients (e.g., @hey-api/openapi-ts) for type-safe API calls in loaders and actions only:
// CORRECT — in a loader (server-side)
export async function loader({ request }: LoaderFunctionArgs) {
const data = await apiClient.getResources();
return { data };
}
// CORRECT — in an action (server-side)
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
await apiClient.createResource(parseFormData(formData));
return Response.json({ success: true });
}
Where NOT to call the API client
Never import or call the API client directly in React components. Components read data exclusively from useLoaderData and useActionData.
// WRONG — in a component
function ResourceList() {
const [data, setData] = useState(null);
useEffect(() => {
apiClient.getResources().then(setData); // ❌ waterfalls, race conditions
}, []);
}
7. Data Revalidation After Actions
Automatic loader revalidation
When an action completes successfully, React Router automatically reruns all loaders on the page to keep UI in sync:
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
await api.deleteResource(String(formData.get("id")));
// Loaders rerun automatically — no manual cache invalidation needed
return Response.json({ success: true });
}
Revalidator for cross-route updates
For actions that affect loaders on other routes, use revalidator:
import { useRevalidator } from "react-router";
function SidePanel() {
const revalidator = useRevalidator();
return (
<button
onClick={() => {
// do something
revalidator.revalidate();
}}
>
Sync
</button>
);
}
8. Anti-Patterns: SPA-Era Data Patterns
Never use these patterns in React Router v7 — they fight the framework:
| SPA anti-pattern | Problem | React Router solution |
|---|---|---|
useEffect for data fetching |
Waterfalls, race conditions, no SSR | Route loaders |
onClick + fetch for mutations |
No progressive enhancement, no revalidation | <Form method="post"> |
Client-side fetch in components |
Bypasses loader caching, invisible to framework | Move to loader or action |
| TanStack Query / SWR for route data | Duplicate cache layer, fights revalidation | useLoaderData is the cache |
useState for form fields |
Extra state, out of sync with DOM | defaultValue + uncontrolled inputs |
useReducer for form state |
Over-engineering what the DOM already does | <Form> + FormData |
| Client-side form validation libraries | Duplicates server logic, false sense of security | HTML5 attributes + server validation |
| Zustand/Redux for server data | Wrong tool — these are for client-only state | Loaders own server data |
| Throwing from actions for user errors | Triggers ErrorBoundary, loses form state | Response.json({ error }) |
9. SSR Entry Points
React Router v7 entry point lifecycle
React Router v7 SSR uses three coordinating files:
entry.server.tsx— called once per HTTP request. Receives the request, renders<ServerRouter>to a stream. This is where per-request providers wrap the render tree. Runs concurrently for parallel requestsentry.client.tsx— runs once in the browser. Hydrates the server-rendered HTML by callinghydrateRootwith<HydratedRouter>. Must mirror the server's provider tree exactlyroot.tsx— the root route. Its loader provides shared data (locale, theme, config) that both entry points consume. TheLayoutexport renders<html>,<head>,<body>
Provider symmetry pattern
Every provider wrapping <ServerRouter> in entry.server.tsx must also wrap <HydratedRouter> in entry.client.tsx, in the same order. Violations cause hydration mismatches or missing context.
// entry.server.tsx
<ProviderA value={serverValueA}>
<ProviderB value={serverValueB}>
<ServerRouter context={reactRouterContext} url={request.url} />
</ProviderB>
</ProviderA>
// entry.client.tsx — same shape
<ProviderA value={clientValueA}>
<ProviderB value={clientValueB}>
<HydratedRouter />
</ProviderB>
</ProviderA>
Third-party SSR integration recipe
When integrating a library that needs per-request state in SSR (i18n, feature flags, A/B testing, styled-components):
- Create a per-request instance in
entry.server.tsx— never reuse a module singleton - Wrap with the library's React provider around
<ServerRouter> - Mirror in
entry.client.tsx— create or initialize the client instance, wrap<HydratedRouter>with the same provider - Bridge data via
root.tsx— if the client needs server-determined values (e.g., detected locale), pass them through the root loader and serialize into the HTML (e.g.,<html lang={locale}>)
The singleton trap: many libraries default to a global instance (i18next, feature flag clients). In SSR, concurrent requests share the Node.js process. Writing to a global then awaiting before reading it back is a race condition. Always use a factory function or createInstance() pattern.
i18next reference implementation
// entry.server.tsx — per-request instance
const locale = getLocale(request);
const i18n = await initI18nForRequest(locale);
// ...
<I18nextProvider i18n={i18n}>
<ServerRouter context={reactRouterContext} url={request.url} />
</I18nextProvider>
10. State Management in React Router
Decision framework: where does each kind of state live?
React Router v7 has distinct locations for different state types. Choosing the wrong location couples components unnecessarily or creates synchronization bugs.
| State type | Location | Hook | Lifetime | Synchronize with | Example |
|---|---|---|---|---|---|
| Server data | Loader return | useLoaderData<typeof loader>() |
One request cycle | Automatic after actions | API responses, database queries |
| Action results | Action return | useActionData<typeof action>() |
Until next action | Manual, typically short-lived | Form errors, success messages |
| URL state | ?key=value in URL |
useSearchParams() |
Until user navigates or clears | Browser history, bookmarks | Filters, pagination, sort order, tab state |
| Transient UI state | useState() in component |
useState() |
Component lifetime | Manual if needed | Collapsed/expanded sections, modal open/close, focus |
| Form input data | DOM (uncontrolled) | FormData in action |
Form submission | Server via action | Text inputs, select values, checkboxes |
| Client-only state | Client context or Zustand | useContext() or selector |
Browser session | Manual | Theme preference, logged-in user, notifications |
Anti-pattern: mixing state locations
Never store server data in useState — it creates a synchronization problem:
// ❌ WRONG — creates a sync bug
function ResourceList() {
const loader = useLoaderData<typeof loader>();
const [items, setItems] = useState(loader.items); // why copy?
// Now you have two sources of truth
// Updating one doesn't update the other
}
// ✅ RIGHT — server data lives in loader
function ResourceList() {
const { items } = useLoaderData<typeof loader>();
return items.map(item => <Item key={item.id} item={item} />);
}
Never use Zustand or Redux for server data — loaders are the cache:
// ❌ WRONG
export async function loader() {
const items = await api.getItems();
store.setItems(items); // don't do this
return { items };
}
// ✅ RIGHT — loader data is the cache
export async function loader() {
const items = await api.getItems();
return { items }; // access via useLoaderData
}
Combining multiple state sources
Components often need data from multiple locations. Combine them cleanly:
export default function ResourcePage() {
// Server data
const { resources } = useLoaderData<typeof loader>();
// URL filters
const [searchParams, setSearchParams] = useSearchParams();
const filterBy = searchParams.get("filter") ?? "all";
// Transient UI state
const [isExpanded, setIsExpanded] = useState(false);
// Action feedback
const actionData = useActionData<typeof action>();
return (
<>
{actionData?.error && <ErrorMessage>{actionData.error}</ErrorMessage>}
<input
value={filterBy}
onChange={(e) => setSearchParams({ filter: e.target.value })}
/>
<button onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? "Hide" : "Show"} Details
</button>
{resources
.filter(r => filterBy === "all" || r.type === filterBy)
.map(resource => (
<ResourceItem key={resource.id} resource={resource} />
))}
</>
);
}
11. Quick Reference
| Task | Hook/API | When |
|---|---|---|
| Load initial page data | loader export |
Before route renders |
| Handle mutations | action export |
<Form method="post"> submission |
| Access loader data | useLoaderData() |
In route components |
| Access action result | useActionData() |
After form submission |
| Manage URL state | useSearchParams() |
Filters, pagination, sort |
| Non-navigating mutations | useFetcher() |
Toggles, background saves |
| Navigation state | useNavigation() |
Submit button disabled states |
| Catch route errors | ErrorBoundary fn |
Route exports |
| Type-safe loader data | <typeof loader> |
In TypeScript with useLoaderData |
| Type-safe action data | Type guard + <typeof action> |
In TypeScript with useActionData |
| Revalidate loaders | revalidator |
After cross-route action |
Cross-references
/react— General React 19 patterns, hooks, component state management, composition, testing/typescript— TypeScript strictness, testing, build tooling/ux-design— Form UX, accessibility, component API design