Fix: Remix Not Working — Loader Returns Undefined, Action Not Triggered, or Nested Route Data Missing
Part of: React & Frontend Errors
Quick Answer
How to fix Remix issues — loader and action setup, nested route outlet, useLoaderData typing, error boundaries, defer with Await, and common React Router v7 migration problems.
The Problem
useLoaderData() returns undefined despite the loader returning data:
// routes/users.tsx
export async function loader() {
return { users: await getUsers() };
}
export default function Users() {
const data = useLoaderData();
console.log(data); // undefined
}Or the action isn’t called when a form is submitted:
export async function action({ request }) {
const form = await request.formData();
// Never runs — form submits to wrong URL
}
export default function NewUser() {
return (
<form method="post" action="/api/users"> {/* Wrong — use Remix Form */}
<input name="name" />
<button type="submit">Create</button>
</form>
);
}Or nested route content doesn’t appear:
// routes/dashboard.tsx
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Child route content never appears */}
</div>
);
}Why This Happens
Remix’s conventions differ significantly from traditional React patterns:
useLoaderDataonly works in the route component that exports theloader— you can’t calluseLoaderDatain a child component and get the parent route’s data. UseuseRouteLoaderData(routeId)for parent data.- Forms must use Remix’s
<Form>or native<form method="post">— the native form’s action attribute must point to the current route URL (or be omitted). Theactionfunction handles POST requests to the route’s URL, not arbitrary API endpoints. - Nested routes require
<Outlet />— parent route components must render<Outlet />to display child route content. Missing<Outlet />makes child routes render nowhere. - Remix v2 / React Router v7 naming changes — Remix was merged into React Router v7. Some API names changed (
loader→clientLoaderin RSC contexts,metaexport changes, etc.).
Remix flips the standard SPA mental model: routes are full-stack units, not view layers, and every route file is simultaneously a server handler and a client component. When useLoaderData() returns undefined, the cause is almost always one of three category errors: the loader is exported but in the wrong file (an app/routes/_index.tsx loader is not visible to app/routes/dashboard.tsx), the loader throws and the error boundary swallows it, or the route is being rendered by a parent layout but you’re calling useLoaderData from a deeply nested component that doesn’t own a loader. The fix differs in each case but the symptom is identical.
The other constant source of surprise is the v2/v7 transition. Remix v2 introduced “future flags” (v2_routeConvention, v2_meta, v2_errorBoundary) that change behavior incrementally; React Router v7 absorbed Remix entirely, deprecating @remix-run/* imports in favor of react-router. A project that mixes the two — say, a tutorial copy-pasted into a freshly scaffolded React Router v7 app — compiles but produces routes that don’t match any URL. Always pin one mode and stay there until you’re ready to migrate intentionally.
Diagnostic Timeline
A loader returns undefined in production. The instinct is to add console.log inside the loader. Usually wrong — the loader is probably running fine.
Minute 0–3. Open the browser DevTools Network panel and filter to the document request (the page URL). Look at the response body: if your loader data is there as JSON embedded in the HTML (Remix serializes it as __remixContext), the loader ran. The bug is in useLoaderData(), not in the loader.
Minute 3–7. If the data is in __remixContext, you’re calling useLoaderData() in a component that doesn’t own a loader. Wrong first suspicion: “the loader didn’t run.” Real fix: replace the call with useRouteLoaderData('routes/dashboard') and pass the route ID of the loader that owns the data. The Remix DevTools panel (“Route IDs” tab) tells you the exact ID string.
Minute 7–12. Action not firing. First wrong suspicion: “the body parser is broken.” Real cause is almost always that the <form> action attribute points to a different URL (or to a static API route), so the request goes elsewhere. Replace the native <form> with <Form method="post"> from @remix-run/react, which submits to the current route. Inspect the request in DevTools — the request URL must match the route URL.
Minute 12–20. Nested route shows nothing. The “minute 0” wrong guess is “child route convention wrong.” Check the parent route file: is <Outlet /> rendered? If not, the child renders into a slot that doesn’t exist. This isn’t optional — it’s the literal rendering portal for child routes. Half of “missing nested content” tickets are missing Outlets.
Minute 20–30. ErrorBoundary catches an exception that hides the real error. Open the network response — if the loader returned a 500, the ErrorBoundary suppresses the original stack. Temporarily disable the boundary, reproduce, then restore. Or read the server console log; Remix prints the original error there before serializing the response.
Minute 30+. Last-resort cause: a future flag mismatch. Open remix.config.js (or vite.config.ts for the new Vite plugin). If v2_routeConvention: false but your files use dashboard.users.tsx dot notation, the routes don’t load at all. Pick one convention, run npx remix routes to confirm the registered route tree, and don’t move on until that tree matches what you expect.
Fix 1: Loader and Action Basics
Every route file can export a loader (GET) and action (POST/PUT/DELETE):
// app/routes/users.tsx
import { useLoaderData, useActionData, Form } from '@remix-run/react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
// loader — runs on every GET request to this route
export async function loader({ request, params }: LoaderFunctionArgs) {
const users = await db.users.findMany();
return json({ users }); // Must return a Response (json() helper creates one)
}
// action — runs on POST/PUT/DELETE requests (form submissions)
export async function action({ request, params }: ActionFunctionArgs) {
const formData = await request.formData();
const name = formData.get('name') as string;
const email = formData.get('email') as string;
if (!name || !email) {
return json({ error: 'Name and email are required' }, { status: 400 });
}
const user = await db.users.create({ data: { name, email } });
return redirect(`/users/${user.id}`); // Redirect after success
}
// Default export — the component
export default function Users() {
const { users } = useLoaderData<typeof loader>(); // Typed!
const actionData = useActionData<typeof action>(); // Error from action
return (
<div>
{actionData?.error && <p className="error">{actionData.error}</p>}
{/* Use Remix's Form — submits to this route's action */}
<Form method="post">
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Add User</button>
</Form>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}Fix 2: Set Up Nested Routes with Outlet
Parent routes must render <Outlet /> to show child route content:
app/routes/
├── dashboard.tsx # Layout route (/dashboard)
├── dashboard.users.tsx # Child route (/dashboard/users)
├── dashboard.settings.tsx # Child route (/dashboard/settings)
└── dashboard._index.tsx # Index route (/dashboard — shown by default)// app/routes/dashboard.tsx — PARENT layout route
import { Outlet, NavLink } from '@remix-run/react';
export default function Dashboard() {
return (
<div className="dashboard">
<nav>
<NavLink to="/dashboard">Overview</NavLink>
<NavLink to="/dashboard/users">Users</NavLink>
<NavLink to="/dashboard/settings">Settings</NavLink>
</nav>
<main>
<Outlet /> {/* ← Child routes render here — required! */}
</main>
</div>
);
}
// app/routes/dashboard._index.tsx — index route for /dashboard
export default function DashboardIndex() {
return <h2>Welcome to Dashboard</h2>;
}
// app/routes/dashboard.users.tsx — /dashboard/users
export async function loader() {
return json({ users: await db.users.findMany() });
}
export default function DashboardUsers() {
const { users } = useLoaderData<typeof loader>();
return <UserList users={users} />;
}File naming convention for nested routes:
# Dot notation creates nested routes
dashboard.tsx → /dashboard (layout)
dashboard._index.tsx → /dashboard (index, shown in Outlet)
dashboard.users.tsx → /dashboard/users
dashboard.users.$id.tsx → /dashboard/users/:id
# Underscore prefix creates pathless layout routes (no URL segment)
_auth.tsx → Layout with no URL segment
_auth.login.tsx → /login (inside _auth layout)
_auth.register.tsx → /register (inside _auth layout)
# Parentheses create optional segments
(lang).about.tsx → /about AND /:lang/about
# Escape dots with [] for literal dots
example[.]com.tsx → /example.comFix 3: Access Parent Loader Data in Child Components
Use useRouteLoaderData to access data from a parent route:
// app/routes/dashboard.tsx — parent loader
export async function loader() {
const user = await getCurrentUser();
return json({ user });
}
// app/routes/dashboard.users.tsx — child route
import { useRouteLoaderData } from '@remix-run/react';
import type { loader as dashboardLoader } from './dashboard';
export default function DashboardUsers() {
// Access parent route's data by route ID
const dashboardData = useRouteLoaderData<typeof dashboardLoader>('routes/dashboard');
const { user } = dashboardData!; // user from parent loader
const { users } = useLoaderData<typeof loader>();
return (
<div>
<p>Logged in as: {user.name}</p>
<UserList users={users} />
</div>
);
}Access root loader data anywhere:
// app/root.tsx
export async function loader() {
const session = await getSession();
return json({ user: session.user, theme: session.theme });
}
// Any nested component
import { useRouteLoaderData } from '@remix-run/react';
import type { loader as rootLoader } from '~/root';
function Header() {
const rootData = useRouteLoaderData<typeof rootLoader>('root');
return <div>Hello, {rootData?.user?.name}</div>;
}Fix 4: Handle Pending States and Optimistic UI
import { Form, useNavigation, useFetcher } from '@remix-run/react';
// useNavigation — global navigation state
function SubmitButton() {
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
);
}
// useFetcher — submit without navigating (for non-navigation mutations)
function LikeButton({ postId }: { postId: string }) {
const fetcher = useFetcher<typeof action>();
const isLiking = fetcher.state !== 'idle';
// Optimistic UI — show the result before the server responds
const optimisticLikes = isLiking
? (currentLikes + 1)
: currentLikes;
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<button type="submit" disabled={isLiking}>
Like {optimisticLikes}
</button>
</fetcher.Form>
);
}
// Programmatic submission with fetcher
function AutoSave({ content }: { content: string }) {
const fetcher = useFetcher();
useEffect(() => {
const timer = setTimeout(() => {
fetcher.submit(
{ content },
{ method: 'post', action: '/draft/save' }
);
}, 1000);
return () => clearTimeout(timer);
}, [content]);
return <span>{fetcher.state === 'idle' ? 'Saved' : 'Saving...'}</span>;
}Fix 5: Defer Non-Critical Data with Await
Use defer to stream slow data after the initial render:
// app/routes/dashboard.tsx
import { defer } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';
import { Suspense } from 'react';
export async function loader() {
// Fast data — awaited before sending response
const user = await getCurrentUser();
// Slow data — not awaited, streamed after initial HTML
const analyticsPromise = getAnalytics(); // Don't await!
const recentActivityPromise = getRecentActivity();
return defer({
user, // Resolved immediately
analytics: analyticsPromise, // Streams when ready
activity: recentActivityPromise,
});
}
export default function Dashboard() {
const { user, analytics, activity } = useLoaderData<typeof loader>();
return (
<div>
{/* user is available immediately */}
<h1>Welcome, {user.name}</h1>
{/* analytics streams in — show fallback until ready */}
<Suspense fallback={<ChartSkeleton />}>
<Await resolve={analytics} errorElement={<p>Failed to load analytics</p>}>
{(data) => <AnalyticsChart data={data} />}
</Await>
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<Await resolve={activity}>
{(data) => <ActivityFeed items={data} />}
</Await>
</Suspense>
</div>
);
}Fix 6: Error Boundaries and Error Handling
// app/routes/users.$id.tsx
import { isRouteErrorResponse, useRouteError } from '@remix-run/react';
export async function loader({ params }: LoaderFunctionArgs) {
const user = await db.users.findUnique({ where: { id: params.id } });
if (!user) {
throw new Response('User not found', { status: 404 });
// OR: throw json({ message: 'User not found' }, { status: 404 });
}
return json({ user });
}
// ErrorBoundary — renders when loader/action throws
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
// Thrown Response (e.g., new Response(..., { status: 404 }))
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
if (error instanceof Error) {
// Unexpected JavaScript error
return (
<div>
<h1>Unexpected Error</h1>
<p>{error.message}</p>
{process.env.NODE_ENV === 'development' && (
<pre>{error.stack}</pre>
)}
</div>
);
}
return <h1>Unknown Error</h1>;
}
// Root error boundary in app/root.tsx catches all unhandled errors
export function ErrorBoundary() {
return (
<html>
<body>
<h1>Application Error</h1>
<p>Something went wrong. <a href="/">Go home</a></p>
</body>
</html>
);
}Still Not Working?
Loader runs on every navigation, not just first load — Remix revalidates all loaders on every navigation by default to keep data fresh. If a loader is expensive, implement shouldRevalidate to control when it reruns:
export function shouldRevalidate({ actionResult, defaultShouldRevalidate }) {
// Only revalidate after an action (mutation), not on normal navigation
if (actionResult) return true;
return false;
}json() helper is deprecated in React Router v7 — React Router v7 (the successor to Remix) deprecates json() and redirect() helpers. Return plain objects from loaders (they’re automatically serialized) and use Response.redirect() directly:
// React Router v7 (Remix v3)
export async function loader() {
const users = await db.users.findMany();
return { users }; // Plain object — no json() needed
}
export async function action({ request }) {
// Process...
return Response.redirect('/success', 302);
}Double data fetch on hydration — if your loader data is fetched twice (once server-side, once client-side), you may have client-side data fetching (useEffect + fetch) in addition to the Remix loader. Remove the useEffect fetch — useLoaderData already gives you the server-fetched data, no client-side fetch needed.
ErrorBoundary shows generic message instead of the thrown response data — useRouteError() returns the thrown value. If you throw new Response('Custom message', { status: 404 }), error.data is the string 'Custom message'. If you throw json({ message: 'Custom' }, { status: 404 }), error.data is { message: 'Custom' }. Mixing the two patterns across your codebase produces inconsistent error UIs. Pick one and stick with it.
useFetcher submits but the page state doesn’t update — useFetcher does not trigger a full revalidation by default. After the fetcher action completes, Remix revalidates loaders, but only on the page the user is currently on. If your fetcher writes to a database that another loader reads from, the loader reruns. If you want to imperatively refresh, call revalidator.revalidate() from useRevalidator() after the fetcher state returns to 'idle'.
Routes work in dev but 404 in production — the Vite plugin and the classic compiler register routes differently. After running remix build (or vite build), inspect build/index.js or build/server/index.js to confirm your route file is listed in the manifest. A common cause is a route filename that’s filtered out because of a leading dot or a non-matching extension. Run npx remix routes locally and compare its output against the deployed manifest.
For related React issues, see Fix: React Hydration Error, Fix: React Suspense Not Triggering, Fix: TanStack Query Not Working, and Fix: React Hook Form Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: Embla Carousel Not Working — Slides Not Scrolling, Autoplay Not Starting, or Thumbnails Not Syncing
How to fix Embla Carousel issues — React setup, slide sizing, autoplay and navigation plugins, loop mode, thumbnail carousels, responsive breakpoints, and vertical scrolling.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.