React 基础体系 · 第 43/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React Router 数据路由:Loader、Action、Revalidation 和错误边界
React Router 传统上主要负责两件事:根据 URL 选择组件,以及提供导航链接。数据路由(Data Router)在此基础上把路由扩展成一个数据生命周期边界:
- 进入路由前,由
loader读取数据; - 提交表单或执行写操作时,由
action处理数据变更; - 数据变更后,通过 revalidation 重新读取受影响的
loader; loader、action或路由组件出错时,由错误边界接管渲染。
这套机制的核心不是“把请求函数放进路由配置”,而是建立一条与导航、提交、取消、错误传播相连接的数据流。
一、数据路由解决什么问题
假设页面 /projects/42 需要项目详情和任务列表。使用普通组件状态时,常见写法是:
function ProjectPage() {
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/projects/42")
.then((response) => response.json())
.then(setProject)
.finally(() => setLoading(false));
}, []);
// 还要处理取消请求、错误、路由参数变化、重复提交……
}
这段代码存在几个结构性问题:
- 组件已经渲染后才开始请求,初始状态必须表示“还没有数据”;
- URL 参数与请求参数容易重复维护;
- 路由离开后,旧请求是否取消需要组件自己处理;
- 提交数据后,哪个页面数据需要重新读取,需要自行约定;
- 请求失败只能在组件内部处理,难以形成统一的错误边界。
数据路由把“路由匹配”和“数据读取”绑定起来:
URL 导航
│
├─ 匹配路由
├─ 调用匹配路由的 loader
├─ loader 全部成功 ──> 渲染页面
└─ 任一 loader 失败 ──> 渲染最近的错误边界
因此,loader 不是一个普通的 useEffect 替代品。它属于路由导航生命周期,路由器知道何时调用它、何时取消它,以及调用失败后应该把错误交给哪一个边界。
二、创建数据路由
数据路由不能通过传统的 <BrowserRouter> 和 <Routes> 自动获得。需要使用能够管理路由数据生命周期的路由器,例如 createBrowserRouter。
一个最小入口如下:
// main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import {
createBrowserRouter,
RouterProvider,
} from "react-router-dom";
import { routerRoutes } from "./router";
const router = createBrowserRouter(routerRoutes);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
);
路由配置使用路由对象:
// router.tsx
import type { RouteObject } from "react-router-dom";
import { RootLayout } from "./RootLayout";
import { HomePage } from "./HomePage";
import { ProjectPage } from "./ProjectPage";
export const routerRoutes: RouteObject[] = [
{
path: "/",
element: <RootLayout />,
children: [
{
index: true,
element: <HomePage />,
},
{
path: "projects/:projectId",
element: <ProjectPage />,
},
],
},
];
这里的 :projectId 是路径参数。访问 /projects/42 时,该路由的 params.projectId 值为 "42"。
需要区分两个概念:
RouteObject描述路由结构;- 数据路由在路由对象上额外声明
loader、action、errorElement等生命周期处理器。
三、Loader:进入路由前读取数据
3.1 Loader 的定义与输入
loader 用于读取某条路由渲染所需的数据:
import type { LoaderFunctionArgs } from "react-router-dom";
export async function projectLoader({
params,
request,
}: LoaderFunctionArgs) {
const projectId = params.projectId;
if (!projectId) {
throw new Response("缺少 projectId", { status: 400 });
}
const url = new URL(`/api/projects/${projectId}`, request.url);
const response = await fetch(url, {
signal: request.signal,
});
if (!response.ok) {
throw new Response("项目不存在", {
status: response.status,
});
}
return response.json() as Promise<Project>;
}
LoaderFunctionArgs 中最重要的字段是:
params:当前 URL 匹配出的动态参数;request:与当前导航关联的 FetchRequest;context:某些路由器或服务端集成可以注入的上下文,具体取决于运行方式。
request.url 包含当前导航的完整 URL。例如访问:
/projects/42?tab=activity
可以这样读取查询参数:
const url = new URL(request.url);
const projectId = params.projectId;
const tab = url.searchParams.get("tab") ?? "overview";
3.2 将 loader 接入路由
// router.tsx
import {
createBrowserRouter,
type RouteObject,
} from "react-router-dom";
import { RootLayout } from "./RootLayout";
import { ProjectPage } from "./ProjectPage";
import { projectLoader } from "./loaders/projectLoader";
export const routerRoutes: RouteObject[] = [
{
path: "/",
element: <RootLayout />,
children: [
{
path: "projects/:projectId",
loader: projectLoader,
element: <ProjectPage />,
},
],
},
];
组件使用 useLoaderData 读取当前路由的 loader 返回值:
// ProjectPage.tsx
import { useLoaderData } from "react-router-dom";
type Project = {
id: string;
name: string;
};
export function ProjectPage() {
const project = useLoaderData() as Project;
return (
<main>
<h1>{project.name}</h1>
<p>项目 ID:{project.id}</p>
</main>
);
}
调用关系是:
导航到 /projects/42
│
├─ 匹配 path="projects/:projectId"
├─ projectLoader({ params: { projectId: "42" }, request })
├─ 返回 Project 对象
├─ 将结果与该路由关联
└─ ProjectPage 通过 useLoaderData() 读取对象
如果 projectLoader 抛出错误,ProjectPage 不会继续作为正常页面渲染;路由器会寻找错误边界。
3.3 Loader 的并行性与依赖
当一个导航同时匹配多个嵌套路由时,路由器通常可以并行调用这些路由的 loader。例如:
const routes: RouteObject[] = [
{
path: "/",
loader: rootLoader,
element: <RootLayout />,
children: [
{
path: "projects/:projectId",
loader: projectLoader,
element: <ProjectPage />,
},
],
},
];
如果 rootLoader 与 projectLoader 彼此没有数据依赖,路由器可以同时发起两个读取请求。这样做的原因是:子路由不需要等待父路由数据时,串行执行会增加总延迟。
但是,路由器不会自动把父 loader 的返回值作为子 loader 的参数。若子路由确实依赖父级结果,有三种常见选择:
- 子路由重新按 ID 读取所需数据;
- 将共享数据放到更高层并通过
useRouteLoaderData读取; - 在服务端或 API 层提供聚合接口。
不要假设“父 loader 返回了用户对象,所以子 loader 可以直接访问它”。loader 之间并不存在隐式共享变量。
3.4 request.signal 与取消
导航从 /projects/1 很快切换到 /projects/2 时,第一个导航可能已经失效。把 request.signal 传给 fetch:
const response = await fetch("/api/projects/1", {
signal: request.signal,
});
可以让浏览器在请求被路由器取消时中止网络请求。否则旧请求仍可能继续占用网络和服务器资源。
这不是“防止所有竞态”的万能机制。服务端已经处理完成的写操作无法因为客户端取消而回滚;它主要解决的是客户端读取请求在导航失效后的取消问题。
四、Action:把数据变更绑定到路由
loader 面向读取,action 面向当前路由范围内的写操作,例如创建、更新或删除数据。
4.1 一个完整的 action
// actions/projectActions.ts
import {
redirect,
type ActionFunctionArgs,
} from "react-router-dom";
export async function createTaskAction({
request,
params,
}: ActionFunctionArgs) {
const projectId = params.projectId;
if (!projectId) {
throw new Response("缺少 projectId", { status: 400 });
}
const formData = await request.formData();
const title = String(formData.get("title") ?? "").trim();
if (!title) {
return {
ok: false,
fieldErrors: {
title: "任务标题不能为空",
},
};
}
const response = await fetch(`/api/projects/${projectId}/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title }),
signal: request.signal,
});
if (!response.ok) {
throw new Response("创建任务失败", {
status: response.status,
});
}
const task = await response.json();
// 创建成功后跳回当前项目页,也可以跳转到任务详情页。
return redirect(`/projects/${projectId}`);
}
action 的典型步骤是:
- 从
request.formData()读取提交内容; - 在服务端或 API 层执行校验与写操作;
- 校验失败时返回结构化结果;
- 成功时返回数据或
redirect; - 成功的 action 完成后,路由器默认重新验证相关 loader。
4.2 使用 <Form> 提交
// ProjectPage.tsx
import {
Form,
useActionData,
useLoaderData,
useNavigation,
} from "react-router-dom";
type ActionData =
| {
ok: false;
fieldErrors: {
title?: string;
};
}
| undefined;
export function ProjectPage() {
const project = useLoaderData() as Project;
const actionData = useActionData() as ActionData;
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<main>
<h1>{project.name}</h1>
<Form method="post">
<label>
新任务
<input name="title" />
</label>
{actionData?.fieldErrors.title && (
<p role="alert">{actionData.fieldErrors.title}</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "创建中…" : "创建任务"}
</button>
</Form>
</main>
);
}
对应路由配置:
{
path: "projects/:projectId",
loader: projectLoader,
action: createTaskAction,
element: <ProjectPage />,
}
这里的 <Form> 不是普通 HTML <form> 的简单别名。它会把提交交给 React Router:
用户提交 Form
│
├─ 导航状态:idle -> submitting
├─ 路由器调用当前匹配路由的 action
├─ action 返回错误对象、数据或 redirect
├─ 成功完成后触发 loader revalidation
└─ 导航状态:loading -> idle
method="post" 表示执行写操作。数据路由的 action 不应被理解为只能处理 POST;路由器也支持 put、patch、delete 等方法,但后端语义和服务端实现仍需自行保证。
4.3 redirect 不等同于组件中的导航
在 action 中:
return redirect(`/projects/${projectId}`);
表示 action 的结果是一次路由重定向。路由器会停止当前结果的正常渲染流程,执行新的导航。
它与组件中调用:
navigate(`/projects/${projectId}`);
的差别在于,前者发生在写操作完成的控制流中。这样可以避免组件先根据旧状态渲染,再由组件额外决定是否跳转。
不要在 action 中返回 HTTP Response 后又期待 React Router 自动把它当作重定向,除非该响应明确表示重定向。最清晰的写法是直接使用 redirect()。
五、useNavigation、useActionData 与 useFetcher
5.1 导航状态
useNavigation 表示当前全局导航或表单导航的状态:
import { useNavigation } from "react-router-dom";
export function GlobalPendingIndicator() {
const navigation = useNavigation();
if (navigation.state === "idle") {
return null;
}
return <div aria-live="polite">正在加载…</div>;
}
通常存在三种状态:
idle:没有进行中的导航;loading:正在加载目标路由的 loader;submitting:正在执行 action 提交。
表单提交成功后,状态可能经历:
idle
-> submitting
-> loading
-> idle
第二个 loading 阶段表示 action 已经结束,路由器正在重新读取 loader。
5.2 useFetcher:不改变 URL 的数据交互
当一个操作不应导致页面导航,例如删除列表项、行内编辑或加载弹窗数据,可以使用 useFetcher:
import { useFetcher } from "react-router-dom";
export function DeleteTaskButton({ taskId }: { taskId: string }) {
const fetcher = useFetcher();
const isDeleting =
fetcher.state === "submitting" ||
fetcher.state === "loading";
return (
<fetcher.Form method="delete" action={`/tasks/${taskId}`}>
<button disabled={isDeleting}>
{isDeleting ? "删除中…" : "删除"}
</button>
</fetcher.Form>
);
}
fetcher.Form 会调用指定路由的 action,但不会像普通 <Form> 那样改变浏览器当前 URL。成功后,数据路由仍会根据 action 结果执行相关 revalidation。
useFetcher 适合局部交互,但它不会自动把所有后端并发问题解决掉。例如用户连续点击删除按钮,后端仍必须正确处理重复请求、幂等性和权限校验。
六、Revalidation:为什么写操作后数据会更新
6.1 定义
Revalidation 可以译为“重新验证”或“重新读取”。在数据路由中,它通常指:
当前路由仍然匹配,但路由器再次调用一个或多个
loader,用服务器最新结果替换旧的 loader 数据。
它解决的是缓存或页面状态失效问题。假设初始任务列表是:
loader 第一次返回:["修复登录", "增加测试"]
用户通过 action 创建“更新文档”后,服务端数据变成:
["修复登录", "增加测试", "更新文档"]
如果只完成 action 而不重新读取,页面仍然持有旧数组。revalidation 产生如下状态变化:
旧 loader 数据
│
├─ action 成功写入服务端
├─ 路由器标记相关数据可能过期
├─ 再次调用 loader
└─ 用新 loader 数据渲染页面
因此,数据路由默认遵循“写入成功后重新读取”的一致性模型,而不是要求每个组件手动修改本地列表。
6.2 Action 完成后的默认行为
当当前路由的 action 成功完成后,React Router 默认会重新验证当前页面上匹配的 loader。这样做是保守但可靠的策略,因为路由器不能仅凭 action 名称推断哪些数据受到了影响。
例如:
页面匹配:
/ -> rootLoader
/projects/42 -> projectLoader
/projects/42/tasks -> taskLoader
在 /projects/42/tasks 提交 action
-> 默认重新验证相关匹配 loader
实际是否重新调用某个 loader,还会结合导航前后的 URL、路径参数和 shouldRevalidate 判断。不同版本的 React Router 可能在边缘场景上有实现差异,因此应用不应依赖未文档化的调用顺序。
6.3 手动触发 revalidation
需要显式刷新时,可以使用 useRevalidator:
import { useRevalidator } from "react-router-dom";
export function RefreshButton() {
const { revalidate, state } = useRevalidator();
return (
<button
onClick={() => revalidate()}
disabled={state === "loading"}
>
{state === "loading" ? "刷新中…" : "刷新"}
</button>
);
}
适合手动 revalidation 的场景包括:
- 用户点击“刷新”;
- 浏览器从后台恢复后,希望重新读取数据;
- 外部事件通知页面数据可能发生变化;
- 长时间停留页面需要重新确认服务端状态。
它不是通用缓存系统。每次 revalidation 都可能产生网络请求,触发频繁轮询时应另外设计节流、缓存或推送机制。
6.4 shouldRevalidate:控制是否重新读取
默认策略过于保守时,可以在路由上声明 shouldRevalidate:
import type {
ShouldRevalidateFunction,
} from "react-router-dom";
const shouldRevalidate: ShouldRevalidateFunction = ({
currentParams,
nextParams,
defaultShouldRevalidate,
}) => {
// projectId 没有变化时,保留默认行为;
// projectId 变化时必须重新读取项目。
if (currentParams.projectId !== nextParams.projectId) {
return true;
}
return defaultShouldRevalidate;
};
配置:
{
path: "projects/:projectId",
loader: projectLoader,
action: createTaskAction,
shouldRevalidate,
element: <ProjectPage />,
}
shouldRevalidate 的返回值含义是:
true:重新调用该路由的 loader;false:跳过该路由的 loader;- 使用
defaultShouldRevalidate:保留路由器默认判断。
可以根据 URL 的变化作判断:
const shouldRevalidate: ShouldRevalidateFunction = ({
currentUrl,
nextUrl,
defaultShouldRevalidate,
}) => {
const currentTab = currentUrl.searchParams.get("tab");
const nextTab = nextUrl.searchParams.get("tab");
if (currentTab !== nextTab) {
return true;
}
return defaultShouldRevalidate;
};
但这里有一个重要风险:返回 false 是在告诉路由器“旧数据仍然可信”。如果 action 实际修改了该 loader 依赖的数据,而 shouldRevalidate 错误地返回 false,页面会继续显示旧内容。
一个常见反例是:
const shouldRevalidate = () => false;
这会让页面看起来性能更好,因为请求减少了;但任何 action 成功后都可能留下过期 UI。除非应用有明确的本地更新或外部缓存失效机制,否则不应无条件禁用 revalidation。
七、Loader 与 Action 的错误传播
错误处理首先要区分“可恢复的业务输入错误”和“路由级故障”。
7.1 返回字段错误,保留当前页面
例如标题为空属于用户可以修正的输入问题:
export async function createTaskAction({
request,
}: ActionFunctionArgs) {
const formData = await request.formData();
const title = String(formData.get("title") ?? "").trim();
if (!title) {
return {
ok: false,
fieldErrors: {
title: "请输入任务标题",
},
};
}
// 执行写操作
return { ok: true };
}
组件用 useActionData 显示:
const actionData = useActionData() as
| {
ok: false;
fieldErrors: { title?: string };
}
| undefined;
这种结果不是异常。action 已经正常完成,只是业务校验失败;页面仍由原组件渲染,并可以保留用户输入。
7.2 抛出错误,交给错误边界
当资源不存在、权限失败或服务器错误时,可以抛出 Response:
if (response.status === 404) {
throw new Response("找不到项目", {
status: 404,
statusText: "Not Found",
});
}
if (!response.ok) {
throw new Response("服务暂时不可用", {
status: response.status,
});
}
也可以抛出普通 Error:
if (!projectId) {
throw new Error("路由参数 projectId 缺失");
}
两者都可以被路由错误边界捕获,但能够通过 isRouteErrorResponse 识别的 Response 更适合表示带 HTTP 状态的路由错误。
八、错误边界:错误发生后渲染什么
8.1 配置 errorElement
import {
Outlet,
useRouteError,
isRouteErrorResponse,
} from "react-router-dom";
export function RootLayout() {
return (
<>
<header>项目管理</header>
<Outlet />
</>
);
}
export function RootErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<main>
<h1>{error.status}</h1>
<p>{error.statusText || String(error.data)}</p>
</main>
);
}
if (error instanceof Error) {
return (
<main>
<h1>页面加载失败</h1>
<p>{error.message}</p>
</main>
);
}
return <main>发生了未知错误。</main>;
}
路由配置:
const routes: RouteObject[] = [
{
path: "/",
element: <RootLayout />,
errorElement: <RootErrorBoundary />,
children: [
{
path: "projects/:projectId",
loader: projectLoader,
action: createTaskAction,
element: <ProjectPage />,
},
],
},
];
错误边界可以捕获的典型错误包括:
loader抛出的错误;action抛出的错误;- 路由组件渲染期间抛出的错误;
- 某些与路由处理相关的错误响应。
8.2 错误边界的冒泡规则
如果子路由没有自己的 errorElement,错误会向父路由冒泡,直到找到最近的错误边界。
/ errorElement: RootErrorBoundary
└── projects/:id 没有 errorElement
└── settings loader 抛错
settings 出错时,错误会交给 RootErrorBoundary。
如果配置了子边界:
{
path: "projects/:projectId/settings",
loader: settingsLoader,
element: <SettingsPage />,
errorElement: <SettingsErrorBoundary />,
}
那么设置页的错误优先由 SettingsErrorBoundary 渲染。这样可以让局部功能失败而不替换整个应用外壳。
8.3 错误边界与 React 组件错误边界的区别
React 的 class error boundary 主要捕获组件渲染、生命周期和构造过程中的错误。React Router 的 errorElement 还与路由数据生命周期相连,可以捕获 loader 和 action 的错误。
因此二者不是完全互相替代的关系:
- 路由数据错误应优先由
errorElement处理; - 与路由无关的组件树错误,仍可以使用 React 错误边界;
- 在 React Router 的数据路由中,错误处理应沿着路由层级组织,而不是把所有请求错误塞入页面组件。
九、一个可运行的端到端示例
下面给出一个完整的项目详情路由。它要求应用存在以下 API:
GET /api/projects/:projectId
POST /api/projects/:projectId/tasks
假设:
GET /api/projects/42
{
"id": "42",
"name": "后台重构",
"tasks": [
{ "id": "t1", "title": "设计数据库迁移" }
]
}
9.1 类型定义
// types.ts
export type Task = {
id: string;
title: string;
};
export type Project = {
id: string;
name: string;
tasks: Task[];
};
export type CreateTaskActionData =
| {
ok: false;
fieldErrors: {
title?: string;
};
}
| {
ok: true;
};
9.2 Loader 与 Action
// projectRoute.ts
import {
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "react-router-dom";
import type { CreateTaskActionData, Project } from "./types";
export async function projectLoader({
params,
request,
}: LoaderFunctionArgs): Promise<Project> {
const { projectId } = params;
if (!projectId) {
throw new Response("缺少项目 ID", { status: 400 });
}
const response = await fetch(`/api/projects/${projectId}`, {
signal: request.signal,
});
if (response.status === 404) {
throw new Response("项目不存在", {
status: 404,
statusText: "Not Found",
});
}
if (!response.ok) {
throw new Response("读取项目失败", {
status: response.status,
});
}
return response.json() as Promise<Project>;
}
export async function projectAction({
params,
request,
}: ActionFunctionArgs): Promise<Response | CreateTaskActionData> {
const { projectId } = params;
if (!projectId) {
throw new Response("缺少项目 ID", { status: 400 });
}
const formData = await request.formData();
const title = String(formData.get("title") ?? "").trim();
if (!title) {
return {
ok: false,
fieldErrors: {
title: "任务标题不能为空",
},
};
}
const response = await fetch(
`/api/projects/${projectId}/tasks`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title }),
signal: request.signal,
},
);
if (!response.ok) {
throw new Response("任务创建失败", {
status: response.status,
});
}
return redirect(`/projects/${projectId}`);
}
这里的 redirect 类型是 Response,所以函数返回类型可以写成 Promise<Response | CreateTaskActionData>。运行时路由器会识别重定向响应。
9.3 页面组件
// ProjectPage.tsx
import {
Form,
useActionData,
useLoaderData,
useNavigation,
} from "react-router-dom";
import type {
CreateTaskActionData,
Project,
} from "./types";
export function ProjectPage() {
const project = useLoaderData() as Project;
const actionData = useActionData() as
| CreateTaskActionData
| undefined;
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const titleError =
actionData?.ok === false
? actionData.fieldErrors.title
: undefined;
return (
<main>
<h1>{project.name}</h1>
<ul>
{project.tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
<Form method="post">
<label>
添加任务
<input name="title" />
</label>
{titleError && (
<p role="alert" style={{ color: "crimson" }}>
{titleError}
</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "保存中…" : "添加"}
</button>
</Form>
</main>
);
}
9.4 路由配置
// router.tsx
import {
createBrowserRouter,
type RouteObject,
useRouteError,
isRouteErrorResponse,
Outlet,
} from "react-router-dom";
import { ProjectPage } from "./ProjectPage";
import {
projectAction,
projectLoader,
} from "./projectRoute";
function RootLayout() {
return (
<>
<header>
<a href="/">项目管理</a>
</header>
<Outlet />
</>
);
}
function RootErrorElement() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<main>
<h1>
{error.status} {error.statusText}
</h1>
<p>{String(error.data)}</p>
</main>
);
}
return (
<main>
<h1>发生错误</h1>
<p>
{error instanceof Error
? error.message
: "未知错误"}
</p>
</main>
);
}
const routes: RouteObject[] = [
{
path: "/",
element: <RootLayout />,
errorElement: <RootErrorElement />,
children: [
{
path: "projects/:projectId",
loader: projectLoader,
action: projectAction,
element: <ProjectPage />,
},
],
},
];
export const router = createBrowserRouter(routes);
注意这里的 <a href="/"> 会触发浏览器级别的完整导航。应用内部切换通常应使用 React Router 的 <Link>:
import { Link } from "react-router-dom";
<Link to="/projects/42">后台重构</Link>
Link 才会让路由器参与导航、调用 loader 并管理 pending 状态。
9.5 预期行为
访问 /projects/42:
1. 路由器匹配 projects/:projectId
2. projectLoader 读取 /api/projects/42
3. ProjectPage 显示项目及任务
提交空标题:
1. 调用 projectAction
2. action 返回 { ok: false, fieldErrors: ... }
3. 页面保持不跳转
4. useActionData 显示字段错误
5. 不执行成功后的 redirect
提交有效标题:
1. 调用 projectAction
2. API 创建任务
3. action 返回 redirect("/projects/42")
4. 路由器重新导航
5. projectLoader 再次读取项目
6. 页面显示新任务
请求项目不存在:
1. projectLoader 收到 404
2. 抛出 Response
3. ProjectPage 不作为正常页面显示
4. RootErrorElement 显示 404 信息
十、客户端与服务端边界
10.1 客户端模式下,loader 不天然运行在服务器
使用 createBrowserRouter 时,loader 通常在浏览器中执行。下面的代码:
const response = await fetch("/api/projects/42");
请求的是浏览器可访问的 HTTP API。loader 本身不会自动获得数据库连接,也不能因为放在 loader 文件中就把数据库查询变成服务端代码。
因此客户端数据路由的边界是:
浏览器 loader
-> HTTP API
-> 服务端鉴权、校验、数据库访问
-> JSON 响应
不能把数据库密码、服务端私钥或仅允许服务器执行的模块导入客户端构建产物。
10.2 服务端渲染时,loader 的执行位置取决于集成方式
React Router 提供了服务端数据路由相关能力,例如静态处理器和服务端渲染集成。使用框架模式时,loader 可以在服务器上执行,并将数据用于初始 HTML 或 hydration。
但“React 19”本身不等于“所有 loader 自动服务端执行”。是否在服务端执行,取决于:
- 使用的是客户端
createBrowserRouter,还是服务端数据路由处理器; - 所选框架如何组织 SSR;
- 数据是否需要在客户端 hydration 后再次读取;
- 服务端与浏览器使用的请求、认证和环境变量是否一致。
生产环境必须明确每个 loader 的运行位置。特别是同一个 loader 若在服务端和浏览器都可能运行,不能假设它始终可以访问 Node.js 专属 API。
10.3 服务端错误与客户端错误
服务端渲染时,loader 可能在生成 HTML 之前失败,框架通常会使用路由错误边界生成错误响应。客户端导航时,则由浏览器中的路由器切换到 errorElement。
这两条路径的 UI 可以相同,但日志和 HTTP 状态码处理可能不同:
SSR loader 失败
-> 服务端记录错误
-> 返回带状态码的 HTML 或错误响应
浏览器 loader 失败
-> 客户端路由器捕获
-> 渲染 errorElement
-> 客户端日志系统记录
错误边界负责展示;权限判定、敏感错误隐藏和服务端日志仍应由服务端完成。
十一、Revalidation 的精确边界与常见误解
11.1 Revalidation 不是本地状态同步
如果 action 创建任务后 loader 重新读取列表,页面会得到服务器结果。这个过程不等价于:
setTasks((tasks) => [...tasks, newTask]);
两者的差异是:
- 本地更新依赖客户端对服务端结果的推断;
- revalidation 以服务端返回值为准;
- 服务端可能添加 ID、时间戳、权限过滤或排序;
- action 可能被其他客户端同时修改,revalidation 可以读取到最终可见状态。
因此 revalidation 更接近“重新确认事实”,而不是“把一个对象追加到数组”。
11.2 Action 成功不代表所有客户端都同步
revalidation 只会更新当前路由实例所管理的数据。另一个浏览器标签页、另一个用户或独立的 Redux store 不会因为本次 action 自动同步。
如果应用同时使用 Redux Toolkit:
const project = useLoaderData();
const userSettings = useSelector(selectUserSettings);
这两类数据的责任不同:
- 路由 loader 适合与 URL、导航、页面进入直接相关的数据;
- Redux Toolkit 适合跨页面共享的客户端状态、复杂本地工作流或不直接依赖路由的数据;
- 不应让同一份服务端数据无理由地同时存在于 loader 和 Redux 中,否则 action 后可能出现两个缓存源不一致。
如果确实需要将 loader 数据写入 Redux,应定义清晰的失效策略;React Router 的 revalidation 不会自动触发 Redux slice 更新。
11.3 shouldRevalidate 不能代替权限与一致性控制
以下代码是不安全的设计:
const shouldRevalidate = () => false;
它只影响客户端是否重新读取,并不会:
- 阻止用户访问资源;
- 保证用户有权限;
- 防止服务端数据改变;
- 使 action 结果自动写入页面;
- 提供缓存失效通知。
权限必须在 API 或服务端 loader 所调用的服务中重新验证。客户端的 shouldRevalidate 只能优化数据读取,不是安全边界。
11.4 错误边界不是表单字段校验组件
如果用户漏填字段,直接抛出错误并进入全页错误边界,通常会损害表单体验:
if (!title) {
throw new Error("标题不能为空");
}
更合适的是返回字段错误:
return {
ok: false,
fieldErrors: {
title: "标题不能为空",
},
};
而数据库连接失败、权限拒绝或目标资源消失,则更适合抛出错误让边界处理。这个区分的依据不是“错误对象长什么样”,而是用户是否可以在当前表单上下文中直接修正问题。
十二、并发、重复提交与故障路径
12.1 导航竞态
用户快速点击:
/projects/1 -> /projects/2 -> /projects/3
路由器可能启动多个 loader。旧导航失效后,其请求应通过 request.signal 取消;最终页面应由最新有效导航决定。
业务代码不应把 loader 结果写入全局可变变量:
// 不推荐
let currentProject: Project | undefined;
export async function loader() {
currentProject = await fetchProject();
return currentProject;
}
这种写法会让不同导航共享隐式状态,放大竞态问题。loader 应通过返回值交给路由器管理。
12.2 重复 action
禁用按钮可以减少重复提交:
<button disabled={navigation.state === "submitting"}>
保存
</button>
但它不能替代服务端幂等性。网络重试、浏览器重复发送或多个客户端都可能造成重复请求。对于“创建订单”“扣款”“发送消息”等操作,应由服务端使用幂等键、唯一约束或事务保证正确性。
12.3 Action 成功、revalidation 失败
可能出现以下路径:
action 写入成功
-> redirect 或 action 完成
-> loader 重新读取
-> loader 因网络错误失败
-> 显示错误边界
这不代表写操作一定回滚。特别是 API 已经返回成功,但后续读取请求超时,服务端数据可能已经成功写入。
因此,错误 UI 不应简单向用户暗示“保存肯定失败”。生产应用应区分:
- 写操作失败;
- 写操作成功但后续读取失败;
- 客户端导航被取消;
- 服务端返回未知状态。
对于关键操作,可以让 action 返回业务结果并跳转到确认页,或使用服务端可查询的操作状态,而不是依赖一次 revalidation 就判断全部结果。
十三、错误诊断方法
13.1 先确认错误发生在哪一层
可以按以下顺序定位:
页面没有显示
│
├─ 路由是否匹配?
├─ loader 是否执行?
├─ request URL 和 params 是否正确?
├─ API 是否返回非 2xx?
├─ action 是否执行?
├─ revalidation 是否触发?
└─ errorElement 是否覆盖了原始错误?
在 loader 中临时记录:
export async function projectLoader({
params,
request,
}: LoaderFunctionArgs) {
console.log("loader", {
url: request.url,
params,
});
// ...
}
在 action 中记录提交方法和字段:
export async function projectAction({
request,
params,
}: ActionFunctionArgs) {
console.log("action", {
method: request.method,
url: request.url,
params,
});
const formData = await request.formData();
console.log("title", formData.get("title"));
// ...
}
不要在生产日志中记录密码、令牌或敏感表单内容。
13.2 页面没有更新时的检查点
如果 action 返回成功,但页面仍显示旧数据,重点检查:
- action 是否真的修改了服务端数据;
- action 是否返回了错误结果而不是成功结果;
shouldRevalidate是否错误地返回false;- loader 是否读取了正确的路径参数;
- API 是否因为缓存、事务延迟或读写分离而返回旧数据;
- 页面是否实际读取的是 Redux 或组件本地状态,而不是
useLoaderData。
尤其要注意:useLoaderData 返回的是当前路由数据快照。action 完成后,路由器更新该路由的数据,组件会重新渲染;但组件中另行复制出的本地状态不会自动同步:
const project = useLoaderData() as Project;
const [tasks, setTasks] = useState(project.tasks);
之后 loader 更新 project.tasks,tasks 仍可能保留旧值。除非有明确理由,不要把 loader 数据复制到独立状态中。
十四、如何划分 Loader、Action、组件状态与全局状态
可以用数据的来源和生命周期作判断:
| 数据类型 | 更适合的位置 |
|---|---|
| 由 URL 参数决定的页面数据 | 路由 loader |
| 通过当前路由表单提交的写操作 | 路由 action |
| 提交中的状态、action 返回的字段错误 | useNavigation、useActionData |
| 不改变 URL 的局部读写操作 | useFetcher |
| 输入框尚未提交的临时值 | 组件本地状态或原生表单 |
| 跨页面共享的客户端工作流状态 | Redux Toolkit 等状态管理工具 |
| 权限、事务、唯一性、服务端校验 | 服务端 |
这不是绝对规则。例如复杂搜索页可以把查询条件放在 URL 中,由 loader 读取;输入框在用户按下搜索前则可以暂存于组件状态。关键是不要让同一状态同时由 URL、loader、Redux 和组件 state 无规则地互相复制。
十五、核心模型
React Router 数据路由可以抽象为三种不同操作:
1. Loader 是读取函数
(loader, URL, params, request)
-> 数据
它描述“当前路由需要什么数据”。
2. Action 是写入函数
(action, method, formData, params, request)
-> 业务结果 / redirect / 错误
它描述“当前路由如何处理用户提交”。
3. Revalidation 是一致性恢复
action 成功或显式刷新
-> 再次执行相关 loader
-> 用服务端结果替换旧数据
4. Error boundary 是故障渲染边界
loader/action/组件错误
-> 沿路由树向上寻找最近 errorElement
-> 在对应边界显示故障 UI
四者连在一起后,页面的数据流不再是“组件挂载、发请求、手动设置状态”的分散过程,而是:
flowchart TD
A[用户导航或提交表单] --> B{操作类型}
B -->|GET 导航| C[匹配路由]
B -->|POST/PUT/PATCH/DELETE| D[调用 action]
C --> E[调用匹配 loader]
D --> F{action 结果}
F -->|字段校验失败| G[useActionData 显示错误]
F -->|redirect| H[执行新导航]
F -->|成功数据| I[触发相关 loader revalidation]
F -->|抛出错误| J[寻找最近 errorElement]
E --> K{loader 结果}
K -->|成功| L[渲染路由组件]
K -->|失败| J
H --> C
I --> E
J --> M[错误边界 UI]
这个模型的关键边界是:组件负责展示和局部交互,路由负责与 URL 相关的数据生命周期,服务端负责最终的数据正确性与安全性。理解这条边界后,loader、action、revalidation 和错误边界就不再是孤立 API,而是一套围绕导航和数据一致性组织起来的路由运行时机制。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 与 Web Components:属性、事件、Ref、类型和互操作
- 下一篇:React 登录与权限:路由、组件、Token 刷新、403 和状态恢复
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论