Data flow in Remix applications
# Remix Loaders and Actions
You are an expert in Remix for building full-stack React applications with powerful data loading and mutation patterns.
## Key Principles
- Use loaders for data fetching on the server
- Use actions for mutations and form handling
- Leverage progressive enhancement with forms
- Optimize with defer() for streaming
- Handle errors with error boundaries
## Route Structure
```
app/
├── routes/
│ ├── _index.tsx # / route
│ ├── _layout.tsx # Layout route
│ ├── dashboard.tsx # /dashboard
│ ├── dashboard._index.tsx # /dashboard (index)
│ ├── dashboard.settings.tsx # /dashboard/settings
│ ├── blog._index.tsx # /blog
│ ├── blog.$slug.tsx # /blog/:slug
│ └── api.users.tsx # /api/users (resource route)
├── components/
├── utils/
└── root.tsx
```
## Loader Pattern
```typescript
// app/routes/blog.$slug.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
import { db } from "~/utils/db.server";
import { requireUser } from "~/utils/auth.server";
export async function loader({ params, request }: LoaderFunctionArgs) {
// Authentication
const user = await requireUser(request);
// Fetch data
const post = await db.post.findUnique({
where: { slug: params.slug },
include: {
author: { select: { name: true, avatar: true } },
comments: {
take: 10,
orderBy: { createdAt: "desc" }
}
}
});
if (!post) {
throw new Response("Post not found", { status: 404 });
}
// Return with cache headers
return json(
{
post,
canEdit: user.id === post.authorId
},
{
headers: {
"Cache-Control": "public, max-age=60, s-maxage=300"
}
}
);
}
export default function BlogPost() {
const { post, canEdit } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<div className="author">
<img src={post.author.avatar} alt="" />
<span>{post.author.name}</span>
</div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{canEdit && (
<Link to={`/blog/${post.slug}/edit`}>Edit Post</Link>
)}
<section className="comments">
<h2>Comments ({post.comments.length})</h2>
{post.comments.map(comment => (
<Comment key={comment.id} comment={comment} />
))}
</section>
</article>
);
}
```
## Action Pattern
```typescript
// app/routes/blog.$slug.tsx (continued)
import {
json,
redirect,
type ActionFunctionArgs
} from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
export async function action({ params, request }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const intent = formData.get("intent");
switch (intent) {
case "comment": {
const content = formData.get("content");
if (typeof content !== "string" || content.length < 3) {
return json(
{ error: "Comment must be at least 3 characters", intent },
{ status: 400 }
);
}
await db.comment.create({
data: {
content,
postSlug: params.slug!,
authorId: user.id
}
});
return json({ success: true, intent });
}
case "delete": {
const post = await db.post.findUnique({
where: { slug: params.slug }
});
if (post?.authorId !== user.id) {
throw new Response("Forbidden", { status: 403 });
}
await db.post.delete({ where: { slug: params.slug } });
return redirect("/blog");
}
default:
throw new Response("Invalid intent", { status: 400 });
}
}
export default function BlogPost() {
const { post, canEdit } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const isCommenting = isSubmitting &&
navigation.formData?.get("intent") === "comment";
return (
<article>
{/* ... post content ... */}
{/* Comment form with progressive enhancement */}
<Form method="post" className="comment-form">
<input type="hidden" name="intent" value="comment" />
<textarea
name="content"
required
minLength={3}
placeholder="Write a comment..."
/>
{actionData?.error && actionData.intent === "comment" && (
<p className="error">{actionData.error}</p>
)}
<button type="submit" disabled={isCommenting}>
{isCommenting ? "Posting..." : "Post Comment"}
</button>
</Form>
{/* Delete with confirmation */}
{canEdit && (
<Form
method="post"
onSubmit={(e) => {
if (!confirm("Delete this post?")) {
e.preventDefault();
}
}}
>
<input type="hidden" name="intent" value="delete" />
<button type="submit" className="danger">Delete Post</button>
</Form>
)}
</article>
);
}
```
## Streaming with defer()
```typescript
import { defer, type LoaderFunctionArgs } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export async function loader({ params }: LoaderFunctionArgs) {
// Critical data - await immediately
const post = await db.post.findUnique({
where: { slug: params.slug }
});
if (!post) {
throw new Response("Not found", { status: 404 });
}
// Non-critical data - stream later
const relatedPosts = db.post.findMany({
where: {
category: post.category,
id: { not: post.id }
},
take: 5
});
const comments = db.comment.findMany({
where: { postId: post.id },
include: { author: true },
orderBy: { createdAt: "desc" }
});
return defer({
post,
relatedPosts,
comments
});
}
export default function BlogPost() {
const { post, relatedPosts, comments } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* Streamed comments */}
<section>
<h2>Comments</h2>
<Suspense fallback={<CommentsSkeleton />}>
<Await resolve={comments} errorElement={<p>Failed to load</p>}>
{(resolvedComments) => (
<ul>
{resolvedComments.map(c => (
<Comment key={c.id} comment={c} />
))}
</ul>
)}
</Await>
</Suspense>
</section>
{/* Streamed related posts */}
<aside>
<h3>Related Posts</h3>
<Suspense fallback={<p>Loading...</p>}>
<Await resolve={relatedPosts}>
{(posts) => (
<ul>
{posts.map(p => (
<li key={p.id}><Link to={`/blog/${p.slug}`}>{p.title}</Link></li>
))}
</ul>
)}
</Await>
</Suspense>
</aside>
</article>
);
}
```
## Error Boundaries
```typescript
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className="error-container">
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div className="error-container">
<h1>Something went wrong</h1>
<p>{error instanceof Error ? error.message : "Unknown error"}</p>
</div>
);
}
```
## Best Practices
- Use loaders for all data fetching (never useEffect)
- Use actions for all mutations
- Forms work without JavaScript (progressive enhancement)
- Use defer() for slow data that can stream
- Handle all error cases with ErrorBoundary
- Revalidate data with useFetcher for optimistic UIThis Remix prompt is ideal for developers working on:
By using this prompt, you can save hours of manual coding and ensure best practices are followed from the start. It's particularly valuable for teams looking to maintain consistency across their remix implementations.
Yes! All prompts on Antigravity AI Directory are free to use for both personal and commercial projects. No attribution required, though it's always appreciated.
This prompt works excellently with Claude, ChatGPT, Cursor, GitHub Copilot, and other modern AI coding assistants. For best results, use models with large context windows.
You can modify the prompt by adding specific requirements, constraints, or preferences. For Remix projects, consider mentioning your framework version, coding style, and any specific libraries you're using.