frontend-react-router-best-practices について
frontend-react-router-best-practicesは、React Routerアプリを開発するためのガイドスキルです。11カテゴリにわたる55のルールで、データ読み込み、フォーム、ストリーミング、ミドルウェア、セキュリティ、ルート構成を扱います。
主な機能
- 01Loaderによるデータ読み込みと並列リクエスト
- 02Action処理、フォーム検証、エラー対応
- 03ストリーミングデータとSingle Fetchのパターン
- 04ミドルウェア、セッション、リクエスト単位のキャッシュ
- 05ルートのセキュリティ、認証、リダイレクト
- 06ルートファイル構成とクエリの共置
活用シーン
- 01React Routerルートのデータ読み込みを設計
- 02検証とエラー通知を備えたフォーム処理の実装
- 03ルートアプリの認証とセキュリティ設定
- 04クエリを整理しリクエスト処理を改善
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ sergiodxa/ agent-skills --skill frontend-react-router-best-practicesインストール後、Claude Code で/frontend-react-router-best-practicesを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
rules/action-client-validation.mdaction-error-handling.mdaction-redirect-after.mdaction-validation.mdaction-zod-transform.mdclient-ip-address.mdclientloader-debounce.mdcors-headers.mddata-only-route-calls-hooks.mddata-parent-route-data.mderror-boundary-layout.mderror-boundary-route.mdform-fetcher-vs-form.mdform-honeypot.mdform-pending-state.mdform-persist-on-error.mdform-reset-on-success.mdlink-prefetch-intent.mdloader-action-abort-signal.mdloader-avoid-waterfalls.mdloader-colocate-queries.mdloader-parallel-fetch.mdloader-request-caching.mdloader-revalidation-patterns.mdloader-typing.mdloader-url-validation.mdmeta-function-v2.mdmiddleware-batcher.mdmiddleware-context-storage.mdmiddleware-logger.md詳細
React Router のベストプラクティス
React Router アプリケーションのパフォーマンス最適化とアーキテクチャのパターンです。データ読み込み、アクション、フォーム、ストリーミング、ルート構成に重点を置いた、11 カテゴリーにわたる 55 のルールを紹介します。
適用する場面
以下の作業を行う際に、このガイドラインを参照してください。
- 新しい React Router のルート(ローダー、アクション)を作成する
- フォームやミューテーションを処理する
- Single Fetch を使ったストリーミングを実装する
- ルートファイルを整理し、クエリーを同じ場所に配置する
- 認証パターンを設定する
- SEO/メタタグを追加する
ルールの概要
データ読み込み(重大)
loader-avoid-waterfalls - @rules/loader-avoid-waterfalls.md
すべてのデータ取得はローダーで行います。コンポーネント内で useEffect を使ってデータを取得しないでください。
// 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>;
}
loader-parallel-fetch - @rules/loader-parallel-fetch.md
ローダー内でデータを並列取得するには Promise.all を使用します。
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 });
}
loader-request-caching - @rules/loader-request-caching.md
API クライアントは、コンテキストを介して同じリクエスト内の呼び出しを重複排除します。データが必要な各ローダーで取得してください。
// 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 });
}
loader-revalidation-patterns - @rules/loader-revalidation-patterns.md
ポーリング、フォーカス、再接続時の再検証には useRevalidator を使用します。
const { revalidate } = useRevalidator();
useEffect(() => {
if (visibilityState === "hidden") return; // Don't poll hidden tabs
let id = setInterval(revalidate, 30000);
return () => clearInterval(id);
}, [revalidate, visibilityState]);
loader-typing - @rules/loader-typing.md
Route.LoaderArgs を使って、適切な TypeScript 型を指定します。
// 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>;
}
loader-url-validation - @rules/loader-url-validation.md
zod または invariant で URL パラメーターを検証します。
// 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) });
}
loader-action-abort-signal - @rules/loader-action-abort-signal.md
リクエストがキャンセルされたときに、非同期処理を中止します。
export async function loader({ request }: Route.LoaderArgs) {
let response = await fetch(url, { signal: request.signal });
return data(await response.json());
}
loader-colocate-queries - @rules/loader-colocate-queries.md
データクエリーは、同じ場所に配置した queries.server.ts ファイルに記述します。
routes/
_.projects/
queries.server.ts # All data fetching functions
route.tsx # Loader calls query functions
components/ # Route-specific components
route-auth-middleware - @rules/route-auth-middleware.md
ミドルウェアで認証し、各ローダー/アクションで認可します。
export const middleware: Route.MiddlewareFunction[] = [
sessionMiddleware,
authMiddleware,
];
export async function loader({ context }: Route.LoaderArgs) {
authorize(context, { requireUser: true, onboardingComplete: true });
return null;
}
ミドルウェアとセキュリティ(高)
middleware-session - @rules/middleware-session.md
リクエストごとにセッションインスタンスを 1 つだけ使用します。
export const middleware: Route.MiddlewareFunction[] = [sessionMiddleware];
middleware-context-storage - @rules/middleware-context-storage.md
引数なしのヘルパーから利用できるよう、AsyncLocalStorage にコンテキスト/リクエストを保存します。
export const middleware: Route.MiddlewareFunction[] = [contextStorageMiddleware];
middleware-batcher - @rules/middleware-batcher.md
リクエスト単位の API/DB 呼び出しを重複排除します。
let result = await getBatcher().batch("key", () => getData());
middleware-request-id - @rules/middleware-request-id.md
ログ記録や相関分析のために、リクエスト ID を追加します。
let requestId = getRequestID();
middleware-logger - @rules/middleware-logger.md
組み込みミドルウェアを使って、一貫した方法でリクエストをログに記録します。
export const middleware: Route.MiddlewareFunction[] = [loggerMiddleware];
middleware-server-timing - @rules/middleware-server-timing.md
レスポンスに Server-Timing の計測値を追加します。
return getTimingCollector().measure("load", "Load data", () => getData());
middleware-singleton - @rules/middleware-singleton.md
キャッシュ用に、リクエストごとのシングルトンを作成します。
let cache = getSingleton(context);
sec-fetch-guards - @rules/sec-fetch-guards.md
Sec-Fetch ヘッダーを使って、クロスサイトのミューテーションリクエストを拒否します。
if (fetchSite(request) === "cross-site") throw new Response(null, { status: 403 });
form-honeypot - @rules/form-honeypot.md
公開フォームにハニーポット入力欄を追加します。
<Form method="post">
<HoneypotInputs />
</Form>
cors-headers - @rules/cors-headers.md
API ルートに CORS ヘッダーを適用します。
return await cors(request, data(await getData()));
safe-redirects - @rules/safe-redirects.md
ユーザー操作によるリダイレクトをサニタイズします。
return redirect(safeRedirect(redirectTo, "/"));
typed-cookies - @rules/typed-cookies.md
スキーマを使って Cookie のペイロードを検証します。
let typed = createTypedCookie({ cookie, schema });
client-ip-address - @rules/client-ip-address.md
信頼できるプロキシヘッダーからクライアント IP を抽出します。
let ip = getClientIPAddress(request);
data-parent-route-data - @rules/data-parent-route-data.md
UI でのみ親ルートのデータにアクセスするには useRouteLoaderData を使用します。ローダーのロジックでは、各ローダーでデータを取得してください(API クライアントはリクエストごとにキャッシュします)。
// 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 });
}
data-only-route-calls-hooks - @rules/data-only-route-calls-hooks.md
useLoaderData/useActionData を呼び出せるのはルートコンポーネントだけです。子コンポーネントには props を渡します。
// 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>
);
}
アクションとフォーム(重大)
action-validation - @rules/action-validation.md
zod スキーマを使ってフォームデータを検証します。
// 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;
}
}
action-error-handling - @rules/action-error-handling.md
検証エラーはスローせずに返します。リダイレクトと不明なエラーは再スローします。
// 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
}
}
action-redirect-after - @rules/action-redirect-after.md
変更が正常に完了した後はリダイレクトして、再送信を防ぎます。
// Good: redirect after mutation
export async function action({ request }: Route.ActionArgs) {
await createItem(formData);
throw redirect("/items"); // Use throw for redirect
}
action-zod-transform - @rules/action-zod-transform.md
検証中の入力値のサニタイズには、Zod の .transform() を使用します。
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"),
});
action-client-validation - @rules/action-client-validation.md
サーバーに送信する前にクライアント側で即座に検証するには、clientAction を使用します。
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
}
フォームのパターン(MEDIUM)
form-fetcher-vs-form - @rules/form-fetcher-vs-form.md
ナビゲーションを伴わない変更には useFetcher を、ナビゲーションには 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>
);
}
form-pending-state - @rules/form-pending-state.md
useNavigation または fetcher.state を使って読み込み状態を表示します。
// 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 });
form-reset-on-success - @rules/form-reset-on-success.md
送信が正常に完了した後、非制御フォームの入力値をリセットします。
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>
);
form-persist-on-error - @rules/form-persist-on-error.md
検証エラー時に入力欄を再表示できるよう、アクションからフィールドの値を返します。
// 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} />;
クライアント関数(MEDIUM)
clientloader-debounce - @rules/clientloader-debounce.md
ルートレベルでデバウンスするには、clientLoader/clientAction を使用します。
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;
移行(HIGH)
migrate-defer-to-data - @rules/migrate-defer-to-data.md
Single Fetch に対応するため、defer() から Promise を使った data() へ移行します。
// 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
});
}
ストリーミング(CRITICAL)
streaming-await-suspense - @rules/streaming-await-suspense.md
ストリーミングデータには、Suspense と組み合わせて Await を使用します。
// 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>
);
}
migrate-jsonhash-to-native - @rules/migrate-jsonhash-to-native.md
jsonHash の使用をやめ、ネイティブの Promise.all または data() のパターンを使用します。
// 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(),
});
}
migrate-json-to-data - @rules/migrate-json-to-data.md
非推奨の json() から data() へ移行します。
// 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 });
migrate-namedaction-to-intent - @rules/migrate-namedaction-to-intent.md
namedAction ヘルパーから z.discriminatedUnion のパターンへ移行します。
// 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");
}
}
エラー処理(MEDIUM)
error-boundary-layout - @rules/error-boundary-layout.md
useRouteError を使って、レイアウトを考慮した ErrorBoundary を実装します。
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>
);
}
error-boundary-route - @rules/error-boundary-route.md
loader/action のエラーを捕捉できるよう、データを取得するルートに ErrorBoundary を追加します。
// Good: route with error boundary
export async function loader() {
// May throw
}
export default function Component() {
// Main component
}
export function ErrorBoundary() {
// Catches loader errors
}
ナビゲーションとリンク(MEDIUM)
link-prefetch-intent - @rules/link-prefetch-intent.md
ホバー時やフォーカス時のナビゲーションを高速化するには、prefetch="intent" を使用します。
// 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>
navigation-avoid-navigate-back - @rules/navigation-avoid-navigate-back.md
アプリ内の戻るリンクでは navigate(-1) を避けます。
<Link to={`/items/${id}`} state={{ back: location.pathname }}>
View
</Link>
prefetch-fetcher-data - @rules/prefetch-fetcher-data.md
fetcher.load() の呼び出しでデータを事前読み込みするには、PrefetchPageLinks を使用します。
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} />}
</>
);
}
リソースルートとレスポンス(MEDIUM)
response-helpers - @rules/response-helpers.md
リソースルートにはレスポンスヘルパーを使用します。
return html("<h1>Hello</h1>");
sse-event-stream - @rules/sse-event-stream.md
eventStream と useEventSource を使って更新をストリーミングします。
return eventStream(request.signal, (send) => {
send({ event: "time", data: new Date().toISOString() });
});
prefetch-cache - @rules/prefetch-cache.md
プリフェッチリクエストには短いキャッシュ期間を設定します。
if (isPrefetch(request)) headers.set("Cache-Control", "private, max-age=5");
ルートの構成(MEDIUM)
route-organization - @rules/route-organization.md
関連ファイルを同じ場所に配置したフォルダールートを使用します。
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.tsx
route-resource-routes - @rules/route-resource-routes.md
UI を持たない API のようなエンドポイントには、リソースルートを使用します。
// 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
route-action-routes - @rules/route-action-routes.md
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">
route-should-revalidate - @rules/route-should-revalidate.md
shouldRevalidate を使って再検証を最適化します。
// 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;
}
route-handle-metadata - @rules/route-handle-metadata.md
ルートのメタデータには、アプリで定義した handle 型とともに handle エクスポートを使用します。
// 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",
};
Meta と SEO(MEDIUM)
meta-function-v2 - @rules/meta-function-v2.md
動的な SEO には、loader のデータを使った 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 ?? [];
ルートの規約(MEDIUM)
route-component-naming - @rules/route-component-naming.md
ルートファイルでは、デフォルトエクスポートに 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} />;
}
route-import-restrictions - @rules/route-import-restrictions.md
ほかのルートファイルからのインポートは避けてください。ルートは互いにインポートせず、共有モジュールをインポートします。
// 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>();