Loading...
Loading...
Compare original and translation side by side
// BAD: fetching in component
function Profile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch("/api/user")
.then((r) => r.json())
.then(setUser);
}, []);
if (!user) return <Spinner />;
return <div>{user.name}</div>;
}
// GOOD: fetch in loader
export async function loader({ request }: Route.LoaderArgs) {
let user = await getUser(request);
return data({ user });
}
export default function Component() {
const { user } = useLoaderData<typeof loader>();
return <div>{user.name}</div>;
}// BAD: fetching in component
function Profile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch("/api/user")
.then((r) => r.json())
.then(setUser);
}, []);
if (!user) return <Spinner />;
return <div>{user.name}</div>;
}
// GOOD: fetch in loader
export async function loader({ request }: Route.LoaderArgs) {
let user = await getUser(request);
return data({ user });
}
export default function Component() {
const { user } = useLoaderData<typeof loader>();
return <div>{user.name}</div>;
}import { data } from "react-router";
// Bad: sequential fetches (slow)
export async function loader({ request }: Route.LoaderArgs) {
let user = await getUser(request);
let posts = await getPosts(user.id);
let comments = await getComments(user.id);
return data({ user, posts, comments });
}
// Good: parallel fetches
export async function loader({ request }: Route.LoaderArgs) {
let user = await getUser(request);
let [posts, comments] = await Promise.all([
getPosts(user.id),
getComments(user.id),
]);
return data({ user, posts, comments });
}import { data } from "react-router";
// Bad: sequential fetches (slow)
export async function loader({ request }: Route.LoaderArgs) {
let user = await getUser(request);
let posts = await getPosts(user.id);
let comments = await getComments(user.id);
return data({ user, posts, comments });
}
// Good: parallel fetches
export async function loader({ request }: Route.LoaderArgs) {
let user = await getUser(request);
let [posts, comments] = await Promise.all([
getPosts(user.id),
getComments(user.id),
]);
return data({ user, posts, comments });
}// Both loaders can call getUser - cached per request
export async function loader({ request, context }: Route.LoaderArgs) {
let client = await authenticate(request, context);
let user = await getUser(client); // Uses cached result if already fetched
return data({ user });
}// Both loaders can call getUser - cached per request
export async function loader({ request, context }: Route.LoaderArgs) {
let client = await authenticate(request, context);
let user = await getUser(client); // Uses cached result if already fetched
return data({ user });
}const { revalidate } = useRevalidator();
useEffect(() => {
if (visibilityState === "hidden") return; // Don't poll hidden tabs
let id = setInterval(revalidate, 30000);
return () => clearInterval(id);
}, [revalidate, visibilityState]);const { revalidate } = useRevalidator();
useEffect(() => {
if (visibilityState === "hidden") return; // Don't poll hidden tabs
let id = setInterval(revalidate, 30000);
return () => clearInterval(id);
}, [revalidate, visibilityState]);// Good: typed loader with useLoaderData
import { data } from "react-router";
import { useLoaderData } from "react-router";
export async function loader({ request, params }: Route.LoaderArgs) {
return data({ user: await getUser(params.id) });
}
export default function Component() {
const { user } = useLoaderData<typeof loader>();
return <div>{user.name}</div>;
}// Good: typed loader with useLoaderData
import { data } from "react-router";
import { useLoaderData } from "react-router";
export async function loader({ request, params }: Route.LoaderArgs) {
return data({ user: await getUser(params.id) });
}
export default function Component() {
const { user } = useLoaderData<typeof loader>();
return <div>{user.name}</div>;
}// Good: validate params early
import { data } from "react-router";
import { z } from "zod";
export async function loader({ params }: Route.LoaderArgs) {
let itemId = z.string().parse(params.itemId);
return data({ item: await getItem(itemId) });
}// Good: validate params early
import { data } from "react-router";
import { z } from "zod";
export async function loader({ params }: Route.LoaderArgs) {
let itemId = z.string().parse(params.itemId);
return data({ item: await getItem(itemId) });
}export async function loader({ request }: Route.LoaderArgs) {
let response = await fetch(url, { signal: request.signal });
return data(await response.json());
}export async function loader({ request }: Route.LoaderArgs) {
let response = await fetch(url, { signal: request.signal });
return data(await response.json());
}queries.server.tsroutes/
_.projects/
queries.server.ts # All data fetching functions
route.tsx # Loader calls query functions
components/ # Route-specific componentsqueries.server.tsroutes/
_.projects/
queries.server.ts # All data fetching functions
route.tsx # Loader calls query functions
components/ # Route-specific componentsexport const middleware: Route.MiddlewareFunction[] = [
sessionMiddleware,
authMiddleware,
];
export async function loader({ context }: Route.LoaderArgs) {
authorize(context, { requireUser: true, onboardingComplete: true });
return null;
}export const middleware: Route.MiddlewareFunction[] = [
sessionMiddleware,
authMiddleware,
];
export async function loader({ context }: Route.LoaderArgs) {
authorize(context, { requireUser: true, onboardingComplete: true });
return null;
}export const middleware: Route.MiddlewareFunction[] = [sessionMiddleware];export const middleware: Route.MiddlewareFunction[] = [sessionMiddleware];export const middleware: Route.MiddlewareFunction[] = [contextStorageMiddleware];export const middleware: Route.MiddlewareFunction[] = [contextStorageMiddleware];let result = await getBatcher().batch("key", () => getData());let result = await getBatcher().batch("key", () => getData());let requestId = getRequestID();let requestId = getRequestID();export const middleware: Route.MiddlewareFunction[] = [loggerMiddleware];export const middleware: Route.MiddlewareFunction[] = [loggerMiddleware];return getTimingCollector().measure("load", "Load data", () => getData());return getTimingCollector().measure("load", "Load data", () => getData());let cache = getSingleton(context);let cache = getSingleton(context);if (fetchSite(request) === "cross-site") throw new Response(null, { status: 403 });if (fetchSite(request) === "cross-site") throw new Response(null, { status: 403 });<Form method="post">
<HoneypotInputs />
</Form><Form method="post">
<HoneypotInputs />
</Form>return await cors(request, data(await getData()));return await cors(request, data(await getData()));return redirect(safeRedirect(redirectTo, "/"));return redirect(safeRedirect(redirectTo, "/"));let typed = createTypedCookie({ cookie, schema });let typed = createTypedCookie({ cookie, schema });let ip = getClientIPAddress(request);let ip = getClientIPAddress(request);useRouteLoaderData// UI-only access - use useRouteLoaderData
export default function ChildRoute() {
const { user } = useRouteLoaderData<typeof profileLoader>("routes/_layout");
return <div>Welcome, {user.name}</div>;
}
// Loader needs data - fetch again (cached, no extra request)
export async function loader({ request }: Route.LoaderArgs) {
let client = await authenticate(request);
let user = await getUser(client); // Uses cached result
let settings = await getSettings(client, user.id);
return data({ settings });
}useRouteLoaderData// UI-only access - use useRouteLoaderData
export default function ChildRoute() {
const { user } = useRouteLoaderData<typeof profileLoader>("routes/_layout");
return <div>Welcome, {user.name}</div>;
}
// Loader needs data - fetch again (cached, no extra request)
export async function loader({ request }: Route.LoaderArgs) {
let client = await authenticate(request);
let user = await getUser(client); // Uses cached result
let settings = await getSettings(client, user.id);
return data({ settings });
}useLoaderDatauseActionData// route.tsx - only place that calls useLoaderData
export default function ItemsRoute() {
const { items } = useLoaderData<typeof loader>();
return <ItemList items={items} />;
}
// components/item-list.tsx - receives data as props
export function ItemList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}useLoaderDatauseActionData// route.tsx - only place that calls useLoaderData
export default function ItemsRoute() {
const { items } = useLoaderData<typeof loader>();
return <ItemList items={items} />;
}
// components/item-list.tsx - receives data as props
export function ItemList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}// Good: schema validation with i18n error messages
export async function action({ request }: Route.ActionArgs) {
let t = await i18n.getFixedT(request);
let formData = await request.formData();
try {
const { amount } = z
.object({
amount: z.coerce
.number()
.min(
minimumAmount,
t("Amount must be at least {{min}}.", { min: minimumAmount }),
),
})
.parse({ amount: formData.get("amount") });
await processAmount(amount);
throw redirect("/success");
} catch (error) {
if (error instanceof z.ZodError) {
return data(
{ errors: error.issues.map(({ message }) => message) },
{ status: 400 },
);
}
throw error;
}
}// Good: schema validation with i18n error messages
export async function action({ request }: Route.ActionArgs) {
let t = await i18n.getFixedT(request);
let formData = await request.formData();
try {
const { amount } = z
.object({
amount: z.coerce
.number()
.min(
minimumAmount,
t("Amount must be at least {{min}}.", { min: minimumAmount }),
),
})
.parse({ amount: formData.get("amount") });
await processAmount(amount);
throw redirect("/success");
} catch (error) {
if (error instanceof z.ZodError) {
return data(
{ errors: error.issues.map(({ message }) => message) },
{ status: 400 },
);
}
throw error;
}
}// Good: proper error handling
export async function action({ request }: Route.ActionArgs) {
try {
// ... validation and mutation
throw redirect("/success");
} catch (error) {
if (error instanceof z.ZodError) {
return data(
{ errors: error.issues.map(({ message }) => message) },
{ status: 400 },
);
}
if (error instanceof Error) {
return data({ errors: [error.message] }, { status: 400 });
}
throw error; // Re-throw redirects and unknown errors
}
}// Good: proper error handling
export async function action({ request }: Route.ActionArgs) {
try {
// ... validation and mutation
throw redirect("/success");
} catch (error) {
if (error instanceof z.ZodError) {
return data(
{ errors: error.issues.map(({ message }) => message) },
{ status: 400 },
);
}
if (error instanceof Error) {
return data({ errors: [error.message] }, { status: 400 });
}
throw error; // Re-throw redirects and unknown errors
}
}// Good: redirect after mutation
export async function action({ request }: Route.ActionArgs) {
await createItem(formData);
throw redirect("/items"); // Use throw for redirect
}// Good: redirect after mutation
export async function action({ request }: Route.ActionArgs) {
await createItem(formData);
throw redirect("/items"); // Use throw for redirect
}const schema = z.object({
// Trim and lowercase email
email: z.string().trim().toLowerCase().pipe(z.string().email()),
// Parse currency string to number
amount: z
.string()
.transform((val) => parseFloat(val.replace(/[,$]/g, "")))
.pipe(z.number().positive()),
// Convert checkbox to boolean
subscribe: z
.string()
.optional()
.transform((val) => val === "on"),
});const schema = z.object({
// Trim and lowercase email
email: z.string().trim().toLowerCase().pipe(z.string().email()),
// Parse currency string to number
amount: z
.string()
.transform((val) => parseFloat(val.replace(/[,$]/g, "")))
.pipe(z.number().positive()),
// Convert checkbox to boolean
subscribe: z
.string()
.optional()
.transform((val) => val === "on"),
});export async function clientAction({
request,
serverAction,
}: Route.ClientActionArgs) {
let formData = await request.formData();
let result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return data(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 },
);
}
return serverAction<typeof action>(); // Validation passed, call server
}export async function clientAction({
request,
serverAction,
}: Route.ClientActionArgs) {
let formData = await request.formData();
let result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return data(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 },
);
}
return serverAction<typeof action>(); // Validation passed, call server
}// Good: useFetcher for in-place updates (no navigation)
function LikeButton({ postId }: { postId: string }) {
let fetcher = useFetcher();
return (
<fetcher.Form method="post" action="/api/like">
<input type="hidden" name="postId" value={postId} />
<button type="submit">Like</button>
</fetcher.Form>
);
}
// Good: Form for navigation after submit
function CreatePostForm() {
return (
<Form method="post" action="/posts/new">
<input name="title" />
<button type="submit">Create</button>
</Form>
);
}// Good: useFetcher for in-place updates (no navigation)
function LikeButton({ postId }: { postId: string }) {
let fetcher = useFetcher();
return (
<fetcher.Form method="post" action="/api/like">
<input type="hidden" name="postId" value={postId} />
<button type="submit">Like</button>
</fetcher.Form>
);
}
// Good: Form for navigation after submit
function CreatePostForm() {
return (
<Form method="post" action="/posts/new">
<input name="title" />
<button type="submit">Create</button>
</Form>
);
}// Good: pending state with fetcher
function SubmitButton() {
let fetcher = useFetcher();
let isPending = fetcher.state !== "idle";
return (
<Button type="submit" isDisabled={isPending}>
{isPending ? <Spinner /> : "Submit"}
</Button>
);
}
// Good: with useSpinDelay to avoid flicker
const isPending = useSpinDelay(fetcher.state !== "idle", { delay: 50 });// Good: pending state with fetcher
function SubmitButton() {
let fetcher = useFetcher();
let isPending = fetcher.state !== "idle";
return (
<Button type="submit" isDisabled={isPending}>
{isPending ? <Spinner /> : "Submit"}
</Button>
);
}
// Good: with useSpinDelay to avoid flicker
const isPending = useSpinDelay(fetcher.state !== "idle", { delay: 50 });const formRef = useRef<HTMLFormElement>(null);
const fetcher = useFetcher<typeof action>();
useEffect(
function resetFormOnSuccess() {
if (fetcher.state === "idle" && fetcher.data?.ok) {
formRef.current?.reset();
}
},
[fetcher.state, fetcher.data],
);
return (
<fetcher.Form method="post" ref={formRef}>
...
</fetcher.Form>
);const formRef = useRef<HTMLFormElement>(null);
const fetcher = useFetcher<typeof action>();
useEffect(
function resetFormOnSuccess() {
if (fetcher.state === "idle" && fetcher.data?.ok) {
formRef.current?.reset();
}
},
[fetcher.state, fetcher.data],
);
return (
<fetcher.Form method="post" ref={formRef}>
...
</fetcher.Form>
);// Action returns fields on error
export async function action({ request }: Route.ActionArgs) {
let formData = await request.formData();
let fields = { email: formData.get("email")?.toString() ?? "" };
let result = schema.safeParse(fields);
if (!result.success) {
return data(
{ errors: result.error.flatten().fieldErrors, fields },
{ status: 400 },
);
}
// ...
}
// Component uses defaultValue
<input name="email" defaultValue={actionData?.fields?.email} />;// Action returns fields on error
export async function action({ request }: Route.ActionArgs) {
let formData = await request.formData();
let fields = { email: formData.get("email")?.toString() ?? "" };
let result = schema.safeParse(fields);
if (!result.success) {
return data(
{ errors: result.error.flatten().fieldErrors, fields },
{ status: 400 },
);
}
// ...
}
// Component uses defaultValue
<input name="email" defaultValue={actionData?.fields?.email} />;import { setTimeout } from "node:timers/promises";
export async function clientLoader({
request,
serverLoader,
}: Route.ClientLoaderArgs) {
// Debounce by 500ms - request.signal aborts if called again
return await setTimeout(500, serverLoader, { signal: request.signal });
}
clientLoader.hydrate = true;import { setTimeout } from "node:timers/promises";
export async function clientLoader({
request,
serverLoader,
}: Route.ClientLoaderArgs) {
// Debounce by 500ms - request.signal aborts if called again
return await setTimeout(500, serverLoader, { signal: request.signal });
}
clientLoader.hydrate = true;// Bad: old defer pattern
import { defer } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
return defer({
critical: await getCriticalData(),
lazy: getLazyData(), // Promise
});
}
// Good: Single Fetch with data() - promises auto-stream
import { data } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
return data({
critical: await getCriticalData(),
lazy: getLazyData(), // Promise automatically streamed
});
}// Bad: old defer pattern
import { defer } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
return defer({
critical: await getCriticalData(),
lazy: getLazyData(), // Promise
});
}
// Good: Single Fetch with data() - promises auto-stream
import { data } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
return data({
critical: await getCriticalData(),
lazy: getLazyData(), // Promise automatically streamed
});
}// Good: Await with Suspense fallback
import { Await, useLoaderData } from "react-router";
import { Suspense } from "react";
export default function Component() {
const { critical, lazy } = useLoaderData<typeof loader>();
return (
<div>
<div>{critical.name}</div>
<Suspense fallback={<Skeleton />}>
<Await resolve={lazy}>{(data) => <LazyContent data={data} />}</Await>
</Suspense>
</div>
);
}// Good: Await with Suspense fallback
import { Await, useLoaderData } from "react-router";
import { Suspense } from "react";
export default function Component() {
const { critical, lazy } = useLoaderData<typeof loader>();
return (
<div>
<div>{critical.name}</div>
<Suspense fallback={<Skeleton />}>
<Await resolve={lazy}>{(data) => <LazyContent data={data} />}</Await>
</Suspense>
</div>
);
}// Bad: jsonHash from remix-utils
import { jsonHash } from "remix-utils/json-hash";
export async function loader({ request }: Route.LoaderArgs) {
return jsonHash({
a: getDataA(),
b: getDataB(),
});
}
// Good: native Promise.all
export async function loader({ request }: Route.LoaderArgs) {
const [a, b] = await Promise.all([getDataA(), getDataB()]);
return data({ a, b });
}
// Good: data() with promises for streaming
import { data } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
return data({
a: getDataA(), // Streams automatically
b: getDataB(),
});
}// Bad: jsonHash from remix-utils
import { jsonHash } from "remix-utils/json-hash";
export async function loader({ request }: Route.LoaderArgs) {
return jsonHash({
a: getDataA(),
b: getDataB(),
});
}
// Good: native Promise.all
export async function loader({ request }: Route.LoaderArgs) {
const [a, b] = await Promise.all([getDataA(), getDataB()]);
return data({ a, b });
}
// Good: data() with promises for streaming
import { data } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
return data({
a: getDataA(), // Streams automatically
b: getDataB(),
});
}// Bad: json() is deprecated
import { json } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
let items = await getItems();
return json({ items });
}
// Good: use data() for all responses
import { data } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
let items = await getItems();
return data({ items });
}
// With status codes
return data({ errors: ["Invalid"] }, { status: 400 });
// Throwing errors
throw data({ message: "Not found" }, { status: 404 });// Bad: json() is deprecated
import { json } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
let items = await getItems();
return json({ items });
}
// Good: use data() for all responses
import { data } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
let items = await getItems();
return data({ items });
}
// With status codes
return data({ errors: ["Invalid"] }, { status: 400 });
// Throwing errors
throw data({ message: "Not found" }, { status: 404 });// Bad: namedAction from remix-utils
import { namedAction } from "remix-utils/named-action";
export async function action({ request }: Route.ActionArgs) {
let formData = await request.formData();
return namedAction(formData, {
async create() {
return data({ success: true });
},
async delete() {
return data({ success: true });
},
});
}
// Good: z.discriminatedUnion for type-safe intent validation
export async function action({ request }: Route.ActionArgs) {
let formData = await request.formData();
let body = z
.discriminatedUnion("intent", [
z.object({ intent: z.literal("create"), title: z.string() }),
z.object({ intent: z.literal("delete"), id: z.string() }),
])
.parse(Object.fromEntries(formData.entries()));
if (body.intent === "create") {
await createItem(client, body);
throw redirect("/items");
}
if (body.intent === "delete") {
await deleteItem(client, body.id);
throw redirect("/items");
}
}// Bad: namedAction from remix-utils
import { namedAction } from "remix-utils/named-action";
export async function action({ request }: Route.ActionArgs) {
let formData = await request.formData();
return namedAction(formData, {
async create() {
return data({ success: true });
},
async delete() {
return data({ success: true });
},
});
}
// Good: z.discriminatedUnion for type-safe intent validation
export async function action({ request }: Route.ActionArgs) {
let formData = await request.formData();
let body = z
.discriminatedUnion("intent", [
z.object({ intent: z.literal("create"), title: z.string() }),
z.object({ intent: z.literal("delete"), id: z.string() }),
])
.parse(Object.fromEntries(formData.entries()));
if (body.intent === "create") {
await createItem(client, body);
throw redirect("/items");
}
if (body.intent === "delete") {
await deleteItem(client, body.id);
throw redirect("/items");
}
}import { useRouteError, isRouteErrorResponse } from "react-router";
export function ErrorBoundary() {
let error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div>
<h1>Error</h1>
<p>{error instanceof Error ? error.message : "Unknown error"}</p>
</div>
);
}import { useRouteError, isRouteErrorResponse } from "react-router";
export function ErrorBoundary() {
let error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div>
<h1>Error</h1>
<p>{error instanceof Error ? error.message : "Unknown error"}</p>
</div>
);
}// Good: route with error boundary
export async function loader() {
// May throw
}
export default function Component() {
// Main component
}
export function ErrorBoundary() {
// Catches loader errors
}// Good: route with error boundary
export async function loader() {
// May throw
}
export default function Component() {
// Main component
}
export function ErrorBoundary() {
// Catches loader errors
}// Good: prefetch on intent
import { Link } from "react-router";
<Link to="/dashboard" prefetch="intent">
Dashboard
</Link>
// Also applies to LinkButton component
<LinkButton to="/settings" prefetch="intent">
Settings
</LinkButton>// Good: prefetch on intent
import { Link } from "react-router";
<Link to="/dashboard" prefetch="intent">
Dashboard
</Link>
// Also applies to LinkButton component
<LinkButton to="/settings" prefetch="intent">
Settings
</LinkButton>navigate(-1)<Link to={`/items/${id}`} state={{ back: location.pathname }}>
View
</Link>navigate(-1)<Link to={`/items/${id}`} state={{ back: location.pathname }}>
View
</Link>import { useFetcher, PrefetchPageLinks } from "react-router";
function ItemDetails({ itemId }: { itemId: string }) {
let fetcher = useFetcher<typeof resourceLoader>();
return (
<>
<PrefetchPageLinks page={`/api/items/${itemId}`} />
<button onClick={() => fetcher.load(`/api/items/${itemId}`)}>
View Details
</button>
{fetcher.data && <Modal data={fetcher.data} />}
</>
);
}import { useFetcher, PrefetchPageLinks } from "react-router";
function ItemDetails({ itemId }: { itemId: string }) {
let fetcher = useFetcher<typeof resourceLoader>();
return (
<>
<PrefetchPageLinks page={`/api/items/${itemId}`} />
<button onClick={() => fetcher.load(`/api/items/${itemId}`)}>
View Details
</button>
{fetcher.data && <Modal data={fetcher.data} />}
</>
);
}return html("<h1>Hello</h1>");return html("<h1>Hello</h1>");eventStreamuseEventSourcereturn eventStream(request.signal, (send) => {
send({ event: "time", data: new Date().toISOString() });
});eventStreamuseEventSourcereturn eventStream(request.signal, (send) => {
send({ event: "time", data: new Date().toISOString() });
});if (isPrefetch(request)) headers.set("Cache-Control", "private, max-age=5");if (isPrefetch(request)) headers.set("Cache-Control", "private, max-age=5");routes/
_.projects/
queries.server.ts # Data fetching functions
actions.server.ts # Action handlers (optional)
route.tsx # Loader, action, component
components/ # Route-specific components
header.tsx
project-card.tsxroutes/
_.projects/
queries.server.ts # Data fetching functions
actions.server.ts # Action handlers (optional)
route.tsx # Loader, action, component
components/ # Route-specific components
header.tsx
project-card.tsx// routes/api.search.tsx - resource route (no default export)
export async function loader({ request }: Route.LoaderArgs) {
let url = new URL(request.url);
let query = url.searchParams.get("q");
let results = await search(query);
return data({ results });
}
// No default export = resource route// routes/api.search.tsx - resource route (no default export)
export async function loader({ request }: Route.LoaderArgs) {
let url = new URL(request.url);
let query = url.searchParams.get("q");
let results = await search(query);
return data({ results });
}
// No default export = resource routeactions.noun-verb.ts// routes/actions.post-create.ts
import { data, redirect } from "react-router";
export async function action({ request, context }: Route.ActionArgs) {
let client = await authenticate(request, { context });
// validation, create post...
return data({ ok: true, post }, { status: 201 });
}
export async function clientAction({ serverAction }: Route.ClientActionArgs) {
let result = await serverAction<typeof action>();
if (result.ok) {
toast.success("Post created");
return redirect(`/posts/${result.post.id}`);
}
toast.error("Failed to create post");
return result;
}
// Usage: <fetcher.Form method="post" action="/actions/post-create">actions.noun-verb.ts// routes/actions.post-create.ts
import { data, redirect } from "react-router";
export async function action({ request, context }: Route.ActionArgs) {
let client = await authenticate(request, { context });
// validation, create post...
return data({ ok: true, post }, { status: 201 });
}
export async function clientAction({ serverAction }: Route.ClientActionArgs) {
let result = await serverAction<typeof action>();
if (result.ok) {
toast.success("Post created");
return redirect(`/posts/${result.post.id}`);
}
toast.error("Failed to create post");
return result;
}
// Usage: <fetcher.Form method="post" action="/actions/post-create">// Good: prevent unnecessary revalidation
export function shouldRevalidate({
currentUrl,
nextUrl,
formAction,
defaultShouldRevalidate,
}) {
// Don't revalidate if only hash changed
if (currentUrl.pathname === nextUrl.pathname) {
return false;
}
return defaultShouldRevalidate;
}// Good: prevent unnecessary revalidation
export function shouldRevalidate({
currentUrl,
nextUrl,
formAction,
defaultShouldRevalidate,
}) {
// Don't revalidate if only hash changed
if (currentUrl.pathname === nextUrl.pathname) {
return false;
}
return defaultShouldRevalidate;
}// Good: handle for hydration and layout control
export const handle: Handle = {
hydrate: true,
};
// For layout routes with more options
export const handle: LayoutHandle = {
hydrate: true,
stickyHeader: true,
footerType: "app",
};// Good: handle for hydration and layout control
export const handle: Handle = {
hydrate: true,
};
// For layout routes with more options
export const handle: LayoutHandle = {
hydrate: true,
stickyHeader: true,
footerType: "app",
};export const meta: Route.MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [];
return [
{ title: data.title },
{ name: "description", content: data.description },
{ property: "og:title", content: data.title },
{ property: "og:description", content: data.description },
{ property: "og:image", content: data.image },
];
};
// Or return from loader for centralized SEO logic
export async function loader({ request }: Route.LoaderArgs) {
let t = await i18n.getFixedT(request);
return data({
// ... data
meta: seo(t, {
title: t("Page Title"),
description: t("Page description"),
og: { title: t("OG Title"), image: "/og-image.png" },
}),
});
}
export const meta: Route.MetaFunction<typeof loader> = ({ data }) =>
data?.meta ?? [];export const meta: Route.MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [];
return [
{ title: data.title },
{ name: "description", content: data.description },
{ property: "og:title", content: data.title },
{ property: "og:description", content: data.description },
{ property: "og:image", content: data.image },
];
};
// Or return from loader for centralized SEO logic
export async function loader({ request }: Route.LoaderArgs) {
let t = await i18n.getFixedT(request);
return data({
// ... data
meta: seo(t, {
title: t("Page Title"),
description: t("Page description"),
og: { title: t("OG Title"), image: "/og-image.png" },
}),
});
}
export const meta: Route.MetaFunction<typeof loader> = ({ data }) =>
data?.meta ?? [];Component// app/routes/_.users/route.tsx
export async function loader() { ... }
export async function action() { ... }
// Always name "Component"
export default function Component() {
let { users } = useLoaderData<typeof loader>();
return <UserList users={users} />;
}Component// app/routes/_.users/route.tsx
export async function loader() { ... }
export async function action() { ... }
// Always name "Component"
export default function Component() {
let { users } = useLoaderData<typeof loader>();
return <UserList users={users} />;
}// Bad: importing from another route
import { UserCard } from "~/routes/users/components/user-card";
// Good: import from shared location
import { UserCard } from "~/components/user-card";
// Exception: import loader/action types for useFetcher inference
import type { action } from "~/routes/api.orders/route";
let fetcher = useFetcher<typeof action>();// Bad: importing from another route
import { UserCard } from "~/routes/users/components/user-card";
// Good: import from shared location
import { UserCard } from "~/components/user-card";
// Exception: import loader/action types for useFetcher inference
import type { action } from "~/routes/api.orders/route";
let fetcher = useFetcher<typeof action>();