mcpField
F
frontend-react-router-best-practices

frontend-react-router-best-practices

github.com/sergiodxa开发92401 次安装38 周前更新
代码仓库

关于 frontend-react-router-best-practices

frontend-react-router-best-practices 是面向 React Router 应用开发者的实践指南,包含 11 个类别、55 条规则。内容覆盖数据加载、表单操作、流式传输、中间件、安全与路由组织。

主要功能

  1. 01Loader 数据加载与并行请求模式
  2. 02Action、表单验证和错误处理
  3. 03流式数据与 Single Fetch 模式
  4. 04中间件、会话和请求范围缓存
  5. 05路由安全、认证与重定向处理
  6. 06路由文件组织与查询共置

适用场景

  1. 01设计 React Router 路由的数据加载方式
  2. 02实现带验证和错误反馈的表单操作
  3. 03为路由应用配置认证与安全防护
  4. 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.md
action-error-handling.md
action-redirect-after.md
action-validation.md
action-zod-transform.md
client-ip-address.md
clientloader-debounce.md
cors-headers.md
data-only-route-calls-hooks.md
data-parent-route-data.md
error-boundary-layout.md
error-boundary-route.md
form-fetcher-vs-form.md
form-honeypot.md
form-pending-state.md
form-persist-on-error.md
form-reset-on-success.md
link-prefetch-intent.md
loader-action-abort-signal.md
loader-avoid-waterfalls.md
loader-colocate-queries.md
loader-parallel-fetch.md
loader-request-caching.md
loader-revalidation-patterns.md
loader-typing.md
loader-url-validation.md
meta-function-v2.md
middleware-batcher.md
middleware-context-storage.md
middleware-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>();

评论

支持 Markdown