react-router-v7
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseReact Router v7 Best Practices
React Router v7 最佳实践
Project Structure
项目结构
The frontend app is in using React Router v7 with SSR (Framework Mode).
apps/web/apps/web/
├── src/
│ ├── routes/ # File-based routing
│ │ ├── layouts/ # Layout routes (root, auth, admin)
│ │ ├── admin/ # Admin routes
│ │ └── _index.tsx # Home page
│ ├── pages/ # Page components
│ ├── components/ # Shared components
│ ├── services/ # API services (http.server.ts)
│ ├── cookies/ # Session management (auth.server.ts)
│ ├── lib/ # Utilities and helpers
│ ├── root.tsx # Root layout
│ └── entry.server.tsx # Server entry
├── react-router.config.ts
└── vite.config.ts前端应用位于目录下,使用带SSR(Framework模式)的React Router v7。
apps/web/apps/web/
├── src/
│ ├── routes/ # 基于文件的路由
│ │ ├── layouts/ # 布局路由(根路由、鉴权路由、管理后台路由)
│ │ ├── admin/ # 管理后台路由
│ │ └── _index.tsx # 首页
│ ├── pages/ # 页面组件
│ ├── components/ # 共享组件
│ ├── services/ # API服务(http.server.ts)
│ ├── cookies/ # 会话管理(auth.server.ts)
│ ├── lib/ # 工具函数与助手方法
│ ├── root.tsx # 根布局
│ └── entry.server.tsx # 服务端入口
├── react-router.config.ts
└── vite.config.tsFile-Based Routing
基于文件的路由
Routes are defined by file structure in :
src/routes/| File | Route |
|---|---|
| |
| |
| |
| |
| |
| |
| |
| Catch-all (404) |
路由由目录下的文件结构定义:
src/routes/| 文件 | 路由 |
|---|---|
| |
| |
| |
| |
| |
| |
| |
| 全局捕获(404) |
Layout Routes
布局路由
routes/
├── layouts/
│ ├── root-layout.tsx # Root layout with Outlet
│ └── auth-layout.tsx # Auth layout (login/register)
├── products.tsx # Layout for /products/*
├── products._index.tsx # /products
└── products.$id.tsx # /products/:idroutes/
├── layouts/
│ ├── root-layout.tsx # 带Outlet的根布局
│ └── auth-layout.tsx # 鉴权布局(登录/注册)
├── products.tsx # /products/*的布局
├── products._index.tsx # /products
└── products.$id.tsx # /products/:idQuick Reference
快速参考
Framework Mode (This Project - Vite plugin with SSR):
ts
// routes.ts
import { index, route, layout } from "@react-router/dev/routes";
export default [
layout("./routes/layouts/root-layout.tsx", [
index("./routes/_index.tsx"),
route("products/:pid", "./routes/products.$id.tsx"),
route("admin/*", "./routes/admin/index.tsx"),
]),
];Data Mode (Alternative for SPAs):
tsx
import { createBrowserRouter, RouterProvider } from "react-router";
const router = createBrowserRouter([
{
path: "/",
Component: Root,
ErrorBoundary: RootErrorBoundary,
loader: rootLoader,
children: [
{ index: true, Component: Home },
{ path: "products/:productId", Component: Product, loader: productLoader },
],
},
]);
ReactDOM.createRoot(root).render(<RouterProvider router={router} />);Framework模式(本项目 - 带SSR的Vite插件):
ts
// routes.ts
import { index, route, layout } from "@react-router/dev/routes";
export default [
layout("./routes/layouts/root-layout.tsx", [
index("./routes/_index.tsx"),
route("products/:pid", "./routes/products.$id.tsx"),
route("admin/*", "./routes/admin/index.tsx"),
]),
];Data模式(SPA替代方案):
tsx
import { createBrowserRouter, RouterProvider } from "react-router";
const router = createBrowserRouter([
{
path: "/",
Component: Root,
ErrorBoundary: RootErrorBoundary,
loader: rootLoader,
children: [
{ index: true, Component: Home },
{ path: "products/:productId", Component: Product, loader: productLoader },
],
},
]);
ReactDOM.createRoot(root).render(<RouterProvider router={router} />);Route Configuration
路由配置
Nested Routes with Outlets
带Outlet的嵌套路由
tsx
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
{ index: true, Component: DashboardHome },
{ path: "settings", Component: Settings },
],
},
]);
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Outlet /> {/* Renders child routes */}
</div>
);
}tsx
createBrowserRouter([
{
path: "/dashboard",
Component: Dashboard,
children: [
{ index: true, Component: DashboardHome },
{ path: "settings", Component: Settings },
],
},
]);
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Outlet /> {/* 渲染子路由 */}
</div>
);
}Dynamic Segments and Splats
动态分段与通配符
tsx
{ path: "teams/:teamId" } // params.teamId
{ path: ":lang?/categories" } // Optional segment
{ path: "files/*" } // Splat: params["*"]tsx
{ path: "teams/:teamId" } // params.teamId
{ path: ":lang?/categories" } // 可选分段
{ path: "files/*" } // 通配符: params["*"]SSR-First Architecture (CRITICAL)
SSR优先架构(至关重要)
Core Principle: Always use loaders for data fetching, actions for mutations.
tsx
// app/routes/products.$id.tsx
import type { Route } from "./+types/products.$id";
// ALWAYS use loaders for server-side data fetching
export async function loader({ params }: Route.LoaderArgs) {
const product = await fetchProduct(params.id);
if (!product) {
throw new Response("Not Found", { status: 404 });
}
return { product };
}
export function meta({ data }: Route.MetaArgs) {
return [
{ title: data?.product.name ?? "Product" },
{ name: "description", content: data?.product.description },
];
}
// Component receives data via props (loaderData)
export default function ProductPage({ loaderData }: Route.ComponentProps) {
const { product } = loaderData;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
);
}核心原则: 始终使用loaders进行数据获取,使用actions进行数据变更。
tsx
// app/routes/products.$id.tsx
import type { Route } from "./+types/products.$id";
// 始终使用loaders进行服务端数据获取
export async function loader({ params }: Route.LoaderArgs) {
const product = await fetchProduct(params.id);
if (!product) {
throw new Response("Not Found", { status: 404 });
}
return { product };
}
export function meta({ data }: Route.MetaArgs) {
return [
{ title: data?.product.name ?? "Product" },
{ name: "description", content: data?.product.description },
];
}
// 组件通过props接收数据(loaderData)
export default function ProductPage({ loaderData }: Route.ComponentProps) {
const { product } = loaderData;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
);
}Authentication Utilities
鉴权工具
Use the auth utilities from :
apps/web/src/cookies/auth.server.tstsx
import { getAuthSession, getAccessTokenOrRedirect, logoutRedirect } from "~/cookies/auth.server";
// In loaders - get session with user info
export async function loader({ request }: Route.LoaderArgs) {
const session = await getAuthSession(request);
// Auto-redirect to login if not authenticated
const accessToken = await getAccessTokenOrRedirect(request);
// Use token for API calls
const data = await authRequest('/api/orders', accessToken);
return { data, user: session.user };
}
// In actions - logout
export async function action({ request }: Route.ActionArgs) {
return logoutRedirect(request);
}使用中的鉴权工具:
apps/web/src/cookies/auth.server.tstsx
import { getAuthSession, getAccessTokenOrRedirect, logoutRedirect } from "~/cookies/auth.server";
// 在loaders中 - 获取包含用户信息的会话
export async function loader({ request }: Route.LoaderArgs) {
const session = await getAuthSession(request);
// 未认证时自动重定向到登录页
const accessToken = await getAccessTokenOrRedirect(request);
// 使用令牌调用API
const data = await authRequest('/api/orders', accessToken);
return { data, user: session.user };
}
// 在actions中 - 登出
export async function action({ request }: Route.ActionArgs) {
return logoutRedirect(request);
}HTTP Service Utilities
HTTP服务工具
Use utilities from :
apps/web/src/services/http.server.tstsx
import { httpRequest, authRequest } from "~/services/http.server";
// Public endpoints (no auth required)
const products = await httpRequest('/api/products');
// Protected endpoints (auto-adds Bearer token)
const orders = await authRequest('/api/orders', accessToken);
// With options
const data = await authRequest('/api/data', accessToken, {
method: 'POST',
body: JSON.stringify(payload),
});Features:
- Automatic 4-second timeout with retry support
- Custom error handling with option
errorHandler - Use pattern for graceful timeout handling
defaultResponse
使用中的工具:
apps/web/src/services/http.server.tstsx
import { httpRequest, authRequest } from "~/services/http.server";
// 公开接口(无需鉴权)
const products = await httpRequest('/api/products');
// 受保护接口(自动添加Bearer令牌)
const orders = await authRequest('/api/orders', accessToken);
// 带选项的请求
const data = await authRequest('/api/data', accessToken, {
method: 'POST',
body: JSON.stringify(payload),
});特性:
- 自动4秒超时与重试支持
- 带选项的自定义错误处理
errorHandler - 使用模式实现优雅的超时处理
defaultResponse
TanStack Query Integration (SSR Pattern)
TanStack Query集成(SSR模式)
Use React Query ONLY for retry logic when loader times out:
tsx
import { useQuery } from "@tanstack/react-query";
export async function loader({ request }: Route.LoaderArgs) {
const session = await getAuthSession(request);
const data = await authRequest('/api/stats', session.accessToken);
return { stats: data ?? defaultResponse }; // defaultResponse on timeout
}
export default function Dashboard({ loaderData }: Route.ComponentProps) {
// React Query with loader data as initialData
const { data: stats, isLoading } = useQuery({
queryKey: ["dashboard-stats"],
queryFn: () => fetch("/api/stats").then((r) => r.json()),
initialData: loaderData.stats,
enabled: loaderData.stats === undefined, // Only fetch if loader timed out
staleTime: 60_000, // 1 minute
});
if (isLoading) return <Skeleton />;
return <StatsDisplay stats={stats} />;
}仅当loader超时时,才使用React Query实现重试逻辑:
tsx
import { useQuery } from "@tanstack/react-query";
export async function loader({ request }: Route.LoaderArgs) {
const session = await getAuthSession(request);
const data = await authRequest('/api/stats', session.accessToken);
return { stats: data ?? defaultResponse }; // 超时返回defaultResponse
}
export default function Dashboard({ loaderData }: Route.ComponentProps) {
// 将loader数据作为initialData传入React Query
const { data: stats, isLoading } = useQuery({
queryKey: ["dashboard-stats"],
queryFn: () => fetch("/api/stats").then((r) => r.json()),
initialData: loaderData.stats,
enabled: loaderData.stats === undefined, // 仅当loader超时才发起请求
staleTime: 60_000, // 1分钟
});
if (isLoading) return <Skeleton />;
return <StatsDisplay stats={stats} />;
}Route with Action (Form Handling)
带Action的路由(表单处理)
tsx
// app/routes/products.new.tsx
import type { Route } from "./+types/products.new";
import { Form, redirect, useActionData } from "react-router";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const name = formData.get("name") as string;
const price = parseFloat(formData.get("price") as string);
// Server-side validation
const errors: Record<string, string> = {};
if (!name) errors.name = "Name is required";
if (isNaN(price)) errors.price = "Valid price is required";
if (Object.keys(errors).length) {
return { errors };
}
const product = await createProduct({ name, price });
return redirect(`/products/${product.id}`);
}
export default function NewProduct({ actionData }: Route.ComponentProps) {
const errors = actionData?.errors;
return (
<Form method="post">
<div>
<label htmlFor="name">Name</label>
<input type="text" name="name" id="name" />
{errors?.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label htmlFor="price">Price</label>
<input type="number" name="price" id="price" step="0.01" />
{errors?.price && <span className="error">{errors.price}</span>}
</div>
<button type="submit">Create Product</button>
</Form>
);
}tsx
// app/routes/products.new.tsx
import type { Route } from "./+types/products.new";
import { Form, redirect, useActionData } from "react-router";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const name = formData.get("name") as string;
const price = parseFloat(formData.get("price") as string);
// 服务端验证
const errors: Record<string, string> = {};
if (!name) errors.name = "Name is required";
if (isNaN(price)) errors.price = "Valid price is required";
if (Object.keys(errors).length) {
return { errors };
}
const product = await createProduct({ name, price });
return redirect(`/products/${product.id}`);
}
export default function NewProduct({ actionData }: Route.ComponentProps) {
const errors = actionData?.errors;
return (
<Form method="post">
<div>
<label htmlFor="name">Name</label>
<input type="text" name="name" id="name" />
{errors?.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label htmlFor="price">Price</label>
<input type="number" name="price" id="price" step="0.01" />
{errors?.price && <span className="error">{errors.price}</span>}
</div>
<button type="submit">Create Product</button>
</Form>
);
}Component Library Integration
组件库集成
Use components from :
@projectx/uitsx
import { Button, Card, Input } from "@projectx/ui";
export default function ProductForm() {
return (
<Card>
<Form method="post">
<Input label="Product Name" name="name" required />
<Button type="submit">Save</Button>
</Form>
</Card>
);
}使用中的组件:
@projectx/uitsx
import { Button, Card, Input } from "@projectx/ui";
export default function ProductForm() {
return (
<Card>
<Form method="post">
<Input label="Product Name" name="name" required />
<Button type="submit">Save</Button>
</Form>
</Card>
);
}Styling with Tailwind CSS v4
使用Tailwind CSS v4进行样式开发
Use utility classes and DaisyUI components:
tsx
import { classnames } from "~/lib/classnames"; // includes tailwind-merge
export default function ProductCard({ product }: { product: Product }) {
return (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h3 className="card-title text-lg font-semibold">{product.name}</h3>
<p className="text-base-content/70">{product.description}</p>
<span className="text-xl font-bold text-primary">
${product.price}
</span>
<div className="card-actions justify-end">
<button className="btn btn-primary">Buy Now</button>
</div>
</div>
</div>
);
}DaisyUI Components: , , , , , ,
btncardalertbadgemodaldrawernavbar使用工具类和DaisyUI组件:
tsx
import { classnames } from "~/lib/classnames"; // 包含tailwind-merge
export default function ProductCard({ product }: { product: Product }) {
return (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h3 className="card-title text-lg font-semibold">{product.name}</h3>
<p className="text-base-content/70">{product.description}</p>
<span className="text-xl font-bold text-primary">
${product.price}
</span>
<div className="card-actions justify-end">
<button className="btn btn-primary">Buy Now</button>
</div>
</div>
</div>
);
}DaisyUI组件: , , , , , ,
btncardalertbadgemodaldrawernavbarError Handling
错误处理
tsx
import { isRouteErrorResponse, useRouteError } from "react-router";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className="alert alert-error">
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div className="alert alert-error">
<h1>Something went wrong</h1>
<p>{error instanceof Error ? error.message : "Unknown error"}</p>
</div>
);
}tsx
import { isRouteErrorResponse, useRouteError } from "react-router";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className="alert alert-error">
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div className="alert alert-error">
<h1>Something went wrong</h1>
<p>{error instanceof Error ? error.message : "Unknown error"}</p>
</div>
);
}Navigation
导航
tsx
import { Link, NavLink, useNavigate } from "react-router";
function Navigation() {
const navigate = useNavigate();
return (
<nav className="navbar bg-base-100">
{/* Basic link */}
<Link to="/products" className="btn btn-ghost">Products</Link>
{/* Active state styling */}
<NavLink
to="/products"
className={({ isActive }) =>
classnames("btn btn-ghost", isActive && "btn-active")
}
>
Products
</NavLink>
{/* Programmatic navigation */}
<button className="btn btn-primary" onClick={() => navigate("/checkout")}>
Go to Checkout
</button>
</nav>
);
}tsx
import { Link, NavLink, useNavigate } from "react-router";
function Navigation() {
const navigate = useNavigate();
return (
<nav className="navbar bg-base-100">
{/* 基础链接 */}
<Link to="/products" className="btn btn-ghost">Products</Link>
{/* 激活状态样式 */}
<NavLink
to="/products"
className={({ isActive }) =>
classnames("btn btn-ghost", isActive && "btn-active")
}
>
Products
</NavLink>
{/* 编程式导航 */}
<button className="btn btn-primary" onClick={() => navigate("/checkout")}>
Go to Checkout
</button>
</nav>
);
}Key Decision Points
关键决策点
Form vs Fetcher
Form vs Fetcher
Use : Creating/deleting with URL change, adding to history
Use : Inline updates, list operations, popovers - no URL change
<Form>useFetcher使用: 创建/删除操作,会改变URL并添加到历史记录
使用: 内联更新、列表操作、弹出层 - 不改变URL
<Form>useFetcherLoader vs useEffect
Loader vs useEffect
Use loader: Data before render, server-side fetch, automatic revalidation
Use useEffect: Client-only data, user-interaction dependent, subscriptions
使用loader: 渲染前获取数据、服务端请求、自动重新验证
使用useEffect: 仅客户端数据、依赖用户交互、订阅操作
Running the Frontend
运行前端项目
bash
undefinedbash
undefinedDevelopment with HMR
带热更新的开发模式
pnpm dev:web
pnpm dev:web
Build for production
生产环境构建
pnpm build:web
pnpm build:web
Type checking
类型检查
pnpm --filter web typecheck
pnpm --filter web typecheck
Run Storybook for components
运行组件Storybook
pnpm storybook
undefinedpnpm storybook
undefinedBest Practices
最佳实践
- ALWAYS use loaders for server-side data fetching (SSR-first)
- ALWAYS use actions for form submissions and mutations
- Use /
authRequestfrom services for API callshttpRequest - Pass to React Query from loader data
initialData - Use to avoid duplicate requests
enabled: initialData === undefined - Handle errors with ErrorBoundary components
- Type routes using the generated files
+types - Use Form component for progressive enhancement
- Validate on server, return errors via action
- Keep components pure - receive data via props
- 始终使用loaders进行服务端数据获取(SSR优先)
- 始终使用actions处理表单提交和数据变更
- **使用/
authRequest**工具调用APIhttpRequest - **将loader数据作为**传入React Query
initialData - **使用**避免重复请求
enabled: initialData === undefined - 使用ErrorBoundary组件处理错误
- 使用生成的文件为路由添加类型
+types - 使用Form组件实现渐进式增强
- 在服务端进行验证,通过action返回错误信息
- 保持组件纯函数特性 - 通过props接收数据
Additional Documentation
额外文档
- Data Loading: See LOADERS.md for loader patterns, parallel loading, search params
- Mutations: See ACTIONS.md for actions, Form, fetchers, validation
- Navigation: See NAVIGATION.md for Link, NavLink, programmatic nav
- Advanced: See ADVANCED.md for error boundaries, protected routes, lazy loading
- 数据加载: 查看LOADERS.md了解loader模式、并行加载、搜索参数
- 数据变更: 查看ACTIONS.md了解actions、Form、fetchers、验证
- 导航: 查看NAVIGATION.md了解Link、NavLink、编程式导航
- 进阶内容: 查看ADVANCED.md了解错误边界、受保护路由、懒加载
Mode Comparison
模式对比
| Feature | Framework Mode | Data Mode | Declarative Mode |
|---|---|---|---|
| Setup | Vite plugin | | |
| Type Safety | Auto-generated types | Manual | Manual |
| SSR Support | Built-in | Manual | Limited |
| Use Case | Full-stack apps (this project) | SPAs with control | Simple/legacy |
| 特性 | Framework模式 | Data模式 | 声明式模式 |
|---|---|---|---|
| 配置方式 | Vite插件 | | |
| 类型安全 | 自动生成类型 | 手动配置 | 手动配置 |
| SSR支持 | 内置 | 手动实现 | 有限支持 |
| 适用场景 | 全栈应用(本项目) | 需要控制的SPA | 简单/遗留项目 |