Skip to content

Fix: Remix Not Working — Loader Returns Undefined, Action Not Triggered, or Nested Route Data Missing

FixDevs ·

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:

  • useLoaderData only works in the route component that exports the loader — you can’t call useLoaderData in a child component and get the parent route’s data. Use useRouteLoaderData(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). The action function 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 (loaderclientLoader in RSC contexts, meta export 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.com

Fix 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles