Fix: Remix Not Working — Loader Returns Undefined, Action Not Triggered, or Nested Route Data Missing
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.).
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}>
❤️ {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.
For related React issues, see Fix: React Hydration Error and Fix: React Suspense Not Triggering.
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.