frontend-react-router-best-practices by sergiodxa/agent-skills
npx skills add https://github.com/sergiodxa/agent-skills --skill frontend-react-router-best-practicesReact Router 应用程序的性能优化和架构模式。包含 11 个类别共 55 条规则,重点关注数据加载、操作、表单、流式传输和路由组织。
在以下情况下参考这些指南:
所有数据获取都在加载器中完成。切勿在组件中使用 useEffect 获取数据。
// 错误:在组件中获取数据
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>;
}
// 正确:在加载器中获取数据
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>;
}
在加载器中使用 Promise.all 进行并行数据获取。
import { data } from "react-router";
// 错误:顺序获取(慢)
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 });
}
// 正确:并行获取
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 });
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
API 客户端通过上下文在同一请求内去重调用。在每个需要数据的加载器中获取数据。
// 两个加载器都可以调用 getUser - 每个请求缓存
export async function loader({ request, context }: Route.LoaderArgs) {
let client = await authenticate(request, context);
let user = await getUser(client); // 如果已获取则使用缓存结果
return data({ user });
}
使用 useRevalidator 进行轮询、焦点和重新连接重新验证。
const { revalidate } = useRevalidator();
useEffect(() => {
if (visibilityState === "hidden") return; // 不轮询隐藏的标签页
let id = setInterval(revalidate, 30000);
return () => clearInterval(id);
}, [revalidate, visibilityState]);
使用 Route.LoaderArgs 进行正确的 TypeScript 类型标注。
// 正确:使用 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>;
}
使用 zod 或 invariant 验证 URL 参数。
// 正确:尽早验证参数
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());
}
将数据查询保存在共置的 queries.server.ts 文件中。
routes/
_.projects/
queries.server.ts # 所有数据获取函数
route.tsx # 加载器调用查询函数
components/ # 路由特定组件
通过中间件进行身份验证,并在每个加载器/操作中进行授权。
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];
将上下文/请求存储在 AsyncLocalStorage 中,以供无参数辅助函数使用。
export const middleware: Route.MiddlewareFunction[] = [contextStorageMiddleware];
对请求范围内的 API/DB 调用进行去重。
let result = await getBatcher().batch("key", () => getData());
为日志记录/关联添加请求 ID。
let requestId = getRequestID();
使用内置中间件一致地记录请求。
export const middleware: Route.MiddlewareFunction[] = [loggerMiddleware];
向响应添加 Server-Timing 测量。
return getTimingCollector().measure("load", "加载数据", () => getData());
为缓存创建每个请求的单例。
let cache = getSingleton(context);
通过 Sec-Fetch 标头拒绝跨站变更请求。
if (fetchSite(request) === "cross-site") throw new Response(null, { status: 403 });
为公共表单添加蜜罐输入。
<Form method="post">
<HoneypotInputs />
</Form>
向 API 路由应用 CORS 标头。
return await cors(request, data(await getData()));
清理用户驱动的重定向。
return redirect(safeRedirect(redirectTo, "/"));
使用模式验证 Cookie 负载。
let typed = createTypedCookie({ cookie, schema });
从受信任的代理标头中提取客户端 IP 地址。
let ip = getClientIPAddress(request);
对于仅 UI 访问父级数据,使用 useRouteLoaderData。对于加载器逻辑,在每个加载器中获取数据(API 客户端每个请求缓存)。
// 仅 UI 访问 - 使用 useRouteLoaderData
export default function ChildRoute() {
const { user } = useRouteLoaderData<typeof profileLoader>("routes/_layout");
return <div>欢迎, {user.name}</div>;
}
// 加载器需要数据 - 再次获取(已缓存,无额外请求)
export async function loader({ request }: Route.LoaderArgs) {
let client = await authenticate(request);
let user = await getUser(client); // 使用缓存结果
let settings = await getSettings(client, user.id);
return data({ settings });
}
只有路由组件调用 useLoaderData/useActionData。子组件接收 props。
// route.tsx - 唯一调用 useLoaderData 的地方
export default function ItemsRoute() {
const { items } = useLoaderData<typeof loader>();
return <ItemList items={items} />;
}
// components/item-list.tsx - 通过 props 接收数据
export function ItemList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
使用 zod 模式验证表单数据。
// 正确:使用国际化错误消息的模式验证
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("金额必须至少为 {{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;
}
}
返回验证错误,不要抛出。重新抛出重定向和未知错误。
// 正确:正确的错误处理
export async function action({ request }: Route.ActionArgs) {
try {
// ... 验证和变更
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; // 重新抛出重定向和未知错误
}
}
在成功变更后重定向以防止重复提交。
// 正确:变更后重定向
export async function action({ request }: Route.ActionArgs) {
await createItem(formData);
throw redirect("/items"); // 使用 throw 进行重定向
}
在验证期间使用 Zod .transform() 进行输入清理。
const schema = z.object({
// 修剪并小写邮箱
email: z.string().trim().toLowerCase().pipe(z.string().email()),
// 将货币字符串解析为数字
amount: z
.string()
.transform((val) => parseFloat(val.replace(/[,$]/g, "")))
.pipe(z.number().positive()),
// 将复选框转换为布尔值
subscribe: z
.string()
.optional()
.transform((val) => val === "on"),
});
使用 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>(); // 验证通过,调用服务器
}
对于非导航变更使用 useFetcher,对于导航使用 Form。
// 正确:useFetcher 用于原地更新(无导航)
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">点赞</button>
</fetcher.Form>
);
}
// 正确:Form 用于提交后导航
function CreatePostForm() {
return (
<Form method="post" action="/posts/new">
<input name="title" />
<button type="submit">创建</button>
</Form>
);
}
使用 useNavigation 或 fetcher.state 显示加载状态。
// 正确:使用 fetcher 的待处理状态
function SubmitButton() {
let fetcher = useFetcher();
let isPending = fetcher.state !== "idle";
return (
<Button type="submit" isDisabled={isPending}>
{isPending ? <Spinner /> : "提交"}
</Button>
);
}
// 正确:使用 useSpinDelay 避免闪烁
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>
);
在验证错误时从操作返回字段值以重新填充输入。
// 操作在错误时返回字段
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 },
);
}
// ...
}
// 组件使用 defaultValue
<input name="email" defaultValue={actionData?.fields?.email} />;
使用 clientLoader/clientAction 在路由级别进行防抖。
import { setTimeout } from "node:timers/promises";
export async function clientLoader({
request,
serverLoader,
}: Route.ClientLoaderArgs) {
// 防抖 500 毫秒 - 如果再次调用,request.signal 会中止
return await setTimeout(500, serverLoader, { signal: request.signal });
}
clientLoader.hydrate = true;
从 defer() 迁移到 data() 并配合 promises 以支持 Single Fetch。
// 错误:旧的 defer 模式
import { defer } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
return defer({
critical: await getCriticalData(),
lazy: getLazyData(), // Promise
});
}
// 正确:使用 data() 的 Single Fetch - promises 自动流式传输
import { data } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
return data({
critical: await getCriticalData(),
lazy: getLazyData(), // Promise 自动流式传输
});
}
使用 Await 配合 Suspense 处理流式数据。
// 正确:Await 配合 Suspense 回退
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>
);
}
停止使用 jsonHash,改用原生的 Promise.all 或 data() 模式。
// 错误:来自 remix-utils 的 jsonHash
import { jsonHash } from "remix-utils/json-hash";
export async function loader({ request }: Route.LoaderArgs) {
return jsonHash({
a: getDataA(),
b: getDataB(),
});
}
// 正确:原生 Promise.all
export async function loader({ request }: Route.LoaderArgs) {
const [a, b] = await Promise.all([getDataA(), getDataB()]);
return data({ a, b });
}
// 正确:使用 promises 进行流式传输的 data()
import { data } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
return data({
a: getDataA(), // 自动流式传输
b: getDataB(),
});
}
从已弃用的 json() 迁移到 data()。
// 错误:json() 已弃用
import { json } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
let items = await getItems();
return json({ items });
}
// 正确:对所有响应使用 data()
import { data } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
let items = await getItems();
return data({ items });
}
// 带状态码
return data({ errors: ["无效"] }, { status: 400 });
// 抛出错误
throw data({ message: "未找到" }, { status: 404 });
从 namedAction 辅助函数迁移到 z.discriminatedUnion 模式。
// 错误:来自 remix-utils 的 namedAction
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 });
},
});
}
// 正确:使用 z.discriminatedUnion 进行类型安全的意图验证
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");
}
}
使用 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>错误</h1>
<p>{error instanceof Error ? error.message : "未知错误"}</p>
</div>
);
}
向具有数据获取的路由添加 ErrorBoundary 以捕获加载器/操作错误。
// 正确:带有错误边界的路由
export async function loader() {
// 可能抛出错误
}
export default function Component() {
// 主组件
}
export function ErrorBoundary() {
// 捕获加载器错误
}
使用 prefetch="intent" 在悬停/聚焦时实现更快的导航。
// 正确:基于意图的预取
import { Link } from "react-router";
<Link to="/dashboard" prefetch="intent">
仪表板
</Link>
// 同样适用于 LinkButton 组件
<LinkButton to="/settings" prefetch="intent">
设置
</LinkButton>
避免在应用内后退链接中使用 navigate(-1)。
<Link to={`/items/${id}`} state={{ back: location.pathname }}>
查看
</Link>
使用 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}`)}>
查看详情
</button>
{fetcher.data && <Modal data={fetcher.data} />}
</>
);
}
对资源路由使用响应辅助函数。
return html("<h1>你好</h1>");
使用 eventStream 和 useEventSource 流式传输更新。
return eventStream(request.signal, (send) => {
send({ event: "time", data: new Date().toISOString() });
});
对预取请求使用短期缓存。
if (isPrefetch(request)) headers.set("Cache-Control", "private, max-age=5");
使用带有共置文件的文件夹路由。
routes/
_.projects/
queries.server.ts # 数据获取函数
actions.server.ts # 操作处理程序(可选)
route.tsx # 加载器、操作、组件
components/ # 路由特定组件
header.tsx
project-card.tsx
对没有 UI 的类 API 端点使用资源路由。
// routes/api.search.tsx - 资源路由(无默认导出)
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 });
}
// 无默认导出 = 资源路由
使用 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 });
// 验证、创建帖子...
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("帖子已创建");
return redirect(`/posts/${result.post.id}`);
}
toast.error("创建帖子失败");
return result;
}
// 用法:<fetcher.Form method="post" action="/actions/post-create">
使用 shouldRevalidate 优化重新验证。
// 正确:防止不必要的重新验证
export function shouldRevalidate({
currentUrl,
nextUrl,
formAction,
defaultShouldRevalidate,
}) {
// 如果仅哈希改变,则不重新验证
if (currentUrl.pathname === nextUrl.pathname) {
return false;
}
return defaultShouldRevalidate;
}
使用 handle 导出配合应用定义的 handle 类型来处理路由元数据。
// 正确:用于水合和布局控制的 handle
export const handle: Handle = {
hydrate: true,
};
// 对于具有更多选项的布局路由
export const handle: LayoutHandle = {
hydrate: true,
stickyHeader: true,
footerType: "app",
};
使用 meta 函数配合加载器数据实现动态 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 },
];
};
// 或者从加载器返回以集中 SEO 逻辑
export async function loader({ request }: Route.LoaderArgs) {
let t = await i18n.getFixedT(request);
return data({
// ... 数据
meta: seo(t, {
title: t("页面标题"),
description: t("页面描述"),
og: { title: t("OG 标题"), 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() { ... }
// 始终命名为 "Component"
export default function Component() {
let { users } = useLoaderData<typeof loader>();
return <UserList users={users} />;
}
避免从其他路由文件导入。路由导入共享模块,而不是相互导入。
// 错误:从另一个路由导入
import { UserCard } from "~/routes/users/components/user-card";
// 正确:从共享位置导入
import { UserCard } from "~/components/user-card";
// 例外:为 useFetcher 推断导入 loader/action 类型
import type { action } from "~/routes/api.orders/route";
let fetcher = useFetcher<typeof action>();
每周安装量
203
仓库
GitHub 星标数
79
首次出现
2026 年 1 月 28 日
安全审计
安装于
opencode174
codex174
github-copilot170
gemini-cli163
cursor145
kimi-cli139
Performance optimization and architecture patterns for React Router applications. Contains 55 rules across 11 categories focused on data loading, actions, forms, streaming, and route organization.
Reference these guidelines when:
All data fetching happens in loaders. Never fetch in components with 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>;
}
Use Promise.all for parallel data fetching in loaders.
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 });
}
API clients dedupe calls within the same request via context. Fetch in each loader that needs data.
// 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 });
}
Use useRevalidator for polling, focus, and reconnect revalidation.
const { revalidate } = useRevalidator();
useEffect(() => {
if (visibilityState === "hidden") return; // Don't poll hidden tabs
let id = setInterval(revalidate, 30000);
return () => clearInterval(id);
}, [revalidate, visibilityState]);
Use proper TypeScript typing with Route.LoaderArgs.
// 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>;
}
Validate URL params with zod or invariant.
// 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) });
}
Abort async work when the request is canceled.
export async function loader({ request }: Route.LoaderArgs) {
let response = await fetch(url, { signal: request.signal });
return data(await response.json());
}
Keep data queries in colocated queries.server.ts files.
routes/
_.projects/
queries.server.ts # All data fetching functions
route.tsx # Loader calls query functions
components/ # Route-specific components
Authenticate via middleware and authorize in each loader/action.
export const middleware: Route.MiddlewareFunction[] = [
sessionMiddleware,
authMiddleware,
];
export async function loader({ context }: Route.LoaderArgs) {
authorize(context, { requireUser: true, onboardingComplete: true });
return null;
}
Keep a single session instance per request.
export const middleware: Route.MiddlewareFunction[] = [sessionMiddleware];
Store context/request in AsyncLocalStorage for arg-less helpers.
export const middleware: Route.MiddlewareFunction[] = [contextStorageMiddleware];
Deduplicate request-scoped API/DB calls.
let result = await getBatcher().batch("key", () => getData());
Add request IDs for logging/correlation.
let requestId = getRequestID();
Log requests consistently with built-in middleware.
export const middleware: Route.MiddlewareFunction[] = [loggerMiddleware];
Add Server-Timing measurements to responses.
return getTimingCollector().measure("load", "Load data", () => getData());
Create per-request singletons for caches.
let cache = getSingleton(context);
Reject cross-site mutation requests via Sec-Fetch headers.
if (fetchSite(request) === "cross-site") throw new Response(null, { status: 403 });
Add honeypot inputs for public forms.
<Form method="post">
<HoneypotInputs />
</Form>
Apply CORS headers to API routes.
return await cors(request, data(await getData()));
Sanitize user-driven redirects.
return redirect(safeRedirect(redirectTo, "/"));
Validate cookie payloads with schemas.
let typed = createTypedCookie({ cookie, schema });
Extract client IP from trusted proxy headers.
let ip = getClientIPAddress(request);
Use useRouteLoaderData for UI-only access to parent data. For loader logic, fetch in each loader (API clients cache per request).
// 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 });
}
Only route components call useLoaderData/useActionData. Children receive 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>
);
}
Validate form data with zod schemas.
// 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;
}
}
Return validation errors, don't throw. 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
}
}
Redirect after successful mutations to prevent resubmission.
// Good: redirect after mutation
export async function action({ request }: Route.ActionArgs) {
await createItem(formData);
throw redirect("/items"); // Use throw for redirect
}
Use Zod .transform() for input sanitization during validation.
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"),
});
Use clientAction for instant client-side validation before hitting the 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
}
Use useFetcher for non-navigation mutations, Form for navigation.
// 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>
);
}
Show loading states with useNavigation or 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 });
Reset uncontrolled form inputs after successful submission.
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>
);
Return field values from actions on validation errors to repopulate inputs.
// 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} />;
Use clientLoader/clientAction to debounce at the route level.
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;
Migrate from defer() to data() with promises for 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
});
}
Use Await with Suspense for streamed data.
// 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>
);
}
Stop using jsonHash, use native Promise.all or data() patterns.
// 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 from deprecated json() to 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 from namedAction helper to z.discriminatedUnion pattern.
// 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");
}
}
Implement layout-aware ErrorBoundary with useRouteError.
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>
);
}
Add ErrorBoundary to routes with data fetching to catch loader/action errors.
// Good: route with error boundary
export async function loader() {
// May throw
}
export default function Component() {
// Main component
}
export function ErrorBoundary() {
// Catches loader errors
}
Use prefetch="intent" for faster navigation on hover/focus.
// 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>
Avoid navigate(-1) for in-app back links.
<Link to={`/items/${id}`} state={{ back: location.pathname }}>
View
</Link>
Use PrefetchPageLinks to preload data for fetcher.load() calls.
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} />}
</>
);
}
Use response helpers for resource routes.
return html("<h1>Hello</h1>");
Stream updates with eventStream and useEventSource.
return eventStream(request.signal, (send) => {
send({ event: "time", data: new Date().toISOString() });
});
Use short caching for prefetch requests.
if (isPrefetch(request)) headers.set("Cache-Control", "private, max-age=5");
Use folder routes with colocated files.
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
Use resource routes for API-like endpoints without UI.
// 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
Centralize reusable actions in dedicated resource routes using actions.noun-verb.ts naming.
// 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">
Optimize revalidation with 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;
}
Use handle export with app-defined handle types for route metadata.
// 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",
};
Use meta function with loader data for dynamic 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 ?? [];
Name the default export Component in route files.
// 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} />;
}
Avoid importing from other route files. Routes import shared modules, not each other.
// 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>();
Weekly Installs
203
Repository
GitHub Stars
79
First Seen
Jan 28, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode174
codex174
github-copilot170
gemini-cli163
cursor145
kimi-cli139
GSAP 框架集成指南:Vue、Svelte 等框架中 GSAP 动画最佳实践
3,000 周安装
assistant-ui runtime 运行时详解:React AI助手状态管理与对话操作指南
876 周安装
Bilibili 字幕提取工具 - 支持 AI 字幕检测与 ASR 转录,一键下载视频字幕
869 周安装
PyTorch开发模式与最佳实践指南:构建稳健高效的深度学习应用
927 周安装
愚者技能:结构化批判性推理工具,压力测试想法与决策,5种推理模式
890 周安装
Pencil Design Skill:AI辅助UI设计工具,从设计到代码生成最佳实践
893 周安装
Godot 4.x GDScript 最佳实践指南:编码标准、架构模式与模板
884 周安装