关于 frontend-react-router-best-practices
frontend-react-router-best-practices 是面向 React Router 应用开发者的实践指南,包含 11 个类别、55 条规则。内容覆盖数据加载、表单操作、流式传输、中间件、安全与路由组织。
主要功能
- 01Loader 数据加载与并行请求模式
- 02Action、表单验证和错误处理
- 03流式数据与 Single Fetch 模式
- 04中间件、会话和请求范围缓存
- 05路由安全、认证与重定向处理
- 06路由文件组织与查询共置
适用场景
- 01设计 React 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 路由(loaders、actions)
- 处理表单和数据变更
- 使用 Single Fetch 实现流式传输
- 组织路由文件并就近放置查询
- 设置身份验证模式
- 添加 SEO/meta 标签
规则摘要
数据加载(关键)
loader-avoid-waterfalls - @rules/loader-avoid-waterfalls.md
所有数据获取都在 loaders 中完成。不要在组件中使用 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
在 loaders 中使用 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 客户端通过 context 在同一请求中对调用去重。在每个需要数据的 loader 中获取数据。
// 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
通过中间件进行身份验证,并在每个 loader/action 中进行授权。
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
每个请求只保留一个 session 实例。
export const middleware: Route.MiddlewareFunction[] = [sessionMiddleware];
middleware-context-storage - @rules/middleware-context-storage.md
将 context/request 存储在 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
使用 schema 验证 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。对于 loader 逻辑,应在每个 loader 中获取数据(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 schema 验证表单数据。
// 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 返回字段值以重新填充输入框。
// 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
将 defer() 迁移为使用 Promise 的 data(),以支持 Single Fetch。
// 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
对流式数据使用 Await 和 Suspense。
// 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 helper 迁移为 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
为获取数据的路由添加 ErrorBoundary,以捕获 loader/action 错误。
// 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
使用 PrefetchPageLinks 预加载 fetcher.load() 调用所需的数据。
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
在资源路由中使用响应 helper。
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 命名方式,在专用资源路由中集中管理可复用的 actions。
// 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
使用 meta 函数和 loader 数据实现动态 SEO。
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>();