| title | Error Handling (React Router v8) | ||||||
|---|---|---|---|---|---|---|---|
| impact | MEDIUM | ||||||
| impactDescription | Unhandled loader or action errors crash the entire page; React Router error boundaries and typed error responses must be used to isolate failures | ||||||
| type | efficiency | ||||||
| tags |
|
Map NextDNS API errors to React Router v8 error boundaries and inline component feedback
React Router v8 provides two error-handling mechanisms:
ErrorBoundaryexport in a route module — catches errors thrown byloaderoractionand renders a fallback UI. In Framework Mode,ErrorBoundaryreceiveserroras a typed prop viaRoute.ErrorBoundaryProps.- Returning error data from
action— returns a typed object (no throw) for inline form validation errors, available asactionDatain the component.
Throw errors in loader for unrecoverable failures. Return error objects from action for
recoverable validation failures.
// ✅ app/routes/profiles.$id.tsx
import { data, isRouteErrorResponse } from 'react-router';
import { nextdnsFetch } from '~/lib/nextdns.server';
import type { Route } from './+types/profiles.$id';
export async function loader({ params }: Route.LoaderArgs) {
try {
const result = await nextdnsFetch<{ data: { id: string; name: string } }>(
`/profiles/${params.id}`,
);
return { profile: result.data };
} catch (err) {
const message = (err as Error).message;
if (message.includes('404')) {
// Use data() to throw typed responses with status codes
throw data('Profile not found', { status: 404 });
}
if (message.includes('401')) {
throw data('Invalid API key', { status: 401 });
}
throw data('Upstream error', { status: 502 });
}
}
// In Framework Mode, ErrorBoundary receives error as a typed prop (not useRouteError hook)
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>
{error.status}: {error.statusText}
</h1>
<p>{error.data}</p>
<a href="/profiles">Back to profiles</a>
</div>
);
}
return (
<div>
<h1>Unexpected Error</h1>
<p>{(error as Error).message}</p>
</div>
);
}
export default function ProfilePage({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1>{loaderData.profile.name}</h1>
</div>
);
}// ✅ app/routes/profiles.tsx (action returns error, does not throw)
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const name = form.get('name') as string;
if (!name?.trim()) {
// Return (not throw) — renders as actionData in the component
return { error: 'Profile name is required' };
}
try {
await nextdnsFetch('/profiles', {
method: 'POST',
body: JSON.stringify({ name }),
});
return null;
} catch (err) {
return { error: (err as Error).message };
}
}
export default function ProfilesPage({ loaderData, actionData }: Route.ComponentProps) {
return (
<div>
{actionData?.error && <p className="error">{actionData.error}</p>}
{/* form... */}
</div>
);
}// ✅ app/root.tsx (add ErrorBoundary to the root route)
import { isRouteErrorResponse, Links, Meta, Scripts } from 'react-router';
import type { Route } from './+types/root';
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<html>
<head>
<Meta />
<Links />
</head>
<body>
<h1>Something went wrong</h1>
{isRouteErrorResponse(error) ? (
<p>
{error.status}: {error.data}
</p>
) : (
<p>{(error as Error).message}</p>
)}
<Scripts />
</body>
</html>
);
}// ❌ Never swallow errors in loaders — the component will receive undefined loaderData
export async function loader({ params }: Route.LoaderArgs) {
try {
return await nextdnsFetch(`/profiles/${params.id}`);
} catch {
return null; // ❌ Component renders with null, no error UI shown
}
}// ❌ Never throw from an action when you want inline validation errors
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
if (!form.get('name')) {
throw data('Name required', { status: 400 }); // ❌ Triggers ErrorBoundary, leaves the page
}
}// ❌ In Framework Mode, do NOT use useRouteError() hook — use the typed prop instead
import { useRouteError } from 'react-router'; // ❌ Data Mode hook
export function ErrorBoundary() {
const error = useRouteError(); // ❌ Use Route.ErrorBoundaryProps instead
}throw data()inloader, return inaction: Thrown errors triggerErrorBoundary; returned objects are available asactionDatafor inline feedback.isRouteErrorResponse: Use this helper to distinguish betweendata()throws and unexpected JavaScript errors inErrorBoundary.- Nested error boundaries: Each route segment can have its own
ErrorBoundary, isolating failures to that segment without crashing the entire layout. Route.ErrorBoundaryProps: In Framework Mode, use the auto-generated type for theerrorprop instead of theuseRouteError()hook (which is for Data Mode).
Symptoms: Loader throws but the app shows a blank page instead of the error boundary.
Solution: Ensure ErrorBoundary is exported from the same route module as the loader, not
just from root.tsx.
Solution: Ensure the action returns a plain object (not undefined) on error. Returning
null is valid for success; returning { error: '...' } gives actionData to the component.