Skip to content
Development
Skill

/remix-v2-data-flow

Remix v2 data loading and mutations. Use when writing loaders, actions, deferred data, revalidation logic, or pending state. Triggers on loader, action, useLoaderData, useActionData, json(), defer(), <Await>, shouldRevalidate, useRevalidator, useNavigation, useTransition (v1

From plugin
beagle
82139 skills2 commands
Install
$ npx -y skills add existential-birds/beagle --skill remix-v2-data-flow --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/remix-v2-data-flow

Context preview

The summary Claude sees to decide when to auto-load this skill.

Remix v2 data loading and mutations. Use when writing loaders, actions, deferred data, revalidation logic, or pending state. Triggers on loader, action, useLoaderData, useActionData, json(), defer(), <Await>, shouldRevalidate, useRevalidator, useNavigation, useTransition (v1

SKILL.md

remix-v2-data-flow.SKILL.md
name: remix-v2-data-flow
description: Remix v2 data loading and mutations. Use when writing loaders, actions, deferred data, revalidation logic, or pending state. Triggers on loader, action, useLoaderData, useActionData, json(), defer(), <Await>, shouldRevalidate, useRevalidator, useNavigation, useTransition (v1 holdover).

Remix v2 Data Flow

Quick Reference

**Loader + typed read**:

import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export async function loader({ request }: LoaderFunctionArgs) {
  const invoices = await db.invoice.findMany();
  return json({ invoices });
}

export default function Invoices() {
  // typeof loader is a type ANNOTATION (not assertion) — drives SerializeFrom<T>.
  const { invoices } = useLoaderData<typeof loader>();
  return <InvoiceList invoices={invoices} />;
}

**Action + redirect-after-success (PRG)**:

import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { useActionData, Form } from "@remix-run/react";

export async function action({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  const parsed = NewProject.safeParse(Object.fromEntries(form));
  if (!parsed.success) return json({ errors: parsed.error.flatten().fieldErrors }, { status: 400 });
  const project = await db.project.create({ data: parsed.data });
  return redirect(`/projects/${project.id}`);
}

Canonical APIs

Route modules export `loader` / `action`; components read results via `useLoaderData<typeof loader>()` and `useActionData<typeof action>()`. After every action, Remix automatically revalidates the loaders of all matching routes on the page, so the UI stays consistent with the server without manual cache invalidation.

Signatures:

  • `loader: ({ request, params, context }: LoaderFunctionArgs) => Response | Promise<Response>` — server-only read, runs on SSR and on client navigations via fetch.
  • `action: ({ request, params, context }: ActionFunctionArgs) => Response | Promise<Response>` — server-only handler for non-GET requests (POST/PUT/PATCH/DELETE).
  • `json(data, init?: number | ResponseInit): TypedResponse<typeof data>` — ergonomic JSON `Response` wrapper with status/headers.
  • `redirect(url, init?: number | ResponseInit): TypedResponse<never>` — 30x response; default 302.

Imports: `@remix-run/node` for server utilities (`json`, `redirect`, `defer`, type args) on Node; substitute `@remix-run/cloudflare` or `@remix-run/deno` for those targets. Hooks and components come from `@remix-run/react`.

Type Annotations, Not Assertions

`useLoaderData<typeof loader>()` is a **type annotation**, not a `as`-style assertion. The generic feeds `SerializeFrom<typeof loader>`, which models the wire-format transformation: `Date` becomes `string`, `Map`/`Set` collapse, `undefined` fields are stripped, class methods vanish. If you call `data.createdAt.getFullYear()` on a `Date` field, that's a runtime bug — the type already says `string`.

When `json()` Is Optional in v2

v2 did **not** change the underlying contract: loaders and actions must return a `Response`. `json()` is the ergonomic wrapper that sets `application/json` and lets you supply status / headers. Bare object returns work in v2 (Remix auto-wraps as `json()`), but `json()` is preferred for explicit status, headers, and clean `TypedResponse<T>` typing. Reach for `json()` whenever you need:

  • A non-200 status code (e.g. `{ status: 400 }` for validation errors).
  • Custom headers (caching, `Set-Cookie`).
  • An explicit `TypedResponse<T>` for clean `useLoaderData<typeof loader>()` inference.

Throwing for Short-Circuits

Throwing a `Response` from a loader or action exits the data function immediately. Use this for auth guards (`throw redirect("/login")`) and 404s (`throw new Response("Not Found", { status: 404 })` or `throw json({ message }, { status: 404 })`). Throwing a plain `Error` will not be classified as a route response by `useRouteError()` / `isRouteErrorResponse()`.

Streaming Rejections: `<Await errorElement>` + `useAsyncError()`

When a promise passed through `defer()` rejects, an `<Await errorElement={...}>` boundary catches it inline — without it, the rejection bubbles to the route's `ErrorBoundary` and tears down the whole page, defeating the streaming benefit. Inside the `errorElement`, call `useAsyncError()` (from `@remix-run/react`) to read the rejection value — this is the streaming analogue of `useRouteError()`.

function ReviewsError() {
  const error = useAsyncError(); // typed as `unknown`
  return <p>Failed to load reviews: {String(error)}</p>;
}

<Suspense fallback={<ReviewsSkeleton />}>
  <Await resolve={reviews} errorElement={<ReviewsError />}>
    {(r) => <ReviewList reviews={r} />}
  </Await>
</Suspense>

Sensitive Data

Everything returned from a loader travels to the browser as JSON. Project to a safe DTO (`{ id, email, name }`) before returning; never return the full Prisma `User`, password hashes, API keys, or internal flags. Loaders execute server-only — but the *return value* is shipped to the client wholesale.

Mutations Belong in Actions

Loaders run on every GET navigation and may be invoked speculatively by prefetch; they also re-run during automatic revalidation. Anything that mutates persistent state must live in `action`, reached via `<Form method="post">` or `useFetcher`. Calling `fetch()` directly from a component to hit a Remix route bypasses revalidation, pending state, and progressive enhancement — use `useFetcher().submit()` / `useFetcher().load()` instead.

Routing Gotchas to Remember

  • **Only the deepest matching action runs.** Index routes nested under a layout collide unless you target them with `<Form action="/things?index" method="post" />`.
  • **`useActionData` is scoped to the current route.** It cannot access action results from parent or child routes; to share, lift the action or use a `useFetche
Read more
Ships withbeagle

Image: NASA, Public Domain. Source Beagle is an Agent Skills marketplace: framework-aware code review, documentation, testing, architectural analysis, and git workflows for any compatible coding agent.

Get the whole plugin

Other skills on beagle.