React 基础体系 · 第 43/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Router 数据路由:Loader、Action、Revalidation 和错误边界

React Router 传统上主要负责两件事:根据 URL 选择组件,以及提供导航链接。数据路由(Data Router)在此基础上把路由扩展成一个数据生命周期边界:

  • 进入路由前,由 loader 读取数据;
  • 提交表单或执行写操作时,由 action 处理数据变更;
  • 数据变更后,通过 revalidation 重新读取受影响的 loader
  • loaderaction 或路由组件出错时,由错误边界接管渲染。

这套机制的核心不是“把请求函数放进路由配置”,而是建立一条与导航、提交、取消、错误传播相连接的数据流。


一、数据路由解决什么问题

假设页面 /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));
  }, []);

  // 还要处理取消请求、错误、路由参数变化、重复提交……
}

这段代码存在几个结构性问题:

  1. 组件已经渲染后才开始请求,初始状态必须表示“还没有数据”;
  2. URL 参数与请求参数容易重复维护;
  3. 路由离开后,旧请求是否取消需要组件自己处理;
  4. 提交数据后,哪个页面数据需要重新读取,需要自行约定;
  5. 请求失败只能在组件内部处理,难以形成统一的错误边界。

数据路由把“路由匹配”和“数据读取”绑定起来:

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 描述路由结构;
  • 数据路由在路由对象上额外声明 loaderactionerrorElement 等生命周期处理器。

三、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:与当前导航关联的 Fetch Request
  • 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 />,
      },
    ],
  },
];

如果 rootLoaderprojectLoader 彼此没有数据依赖,路由器可以同时发起两个读取请求。这样做的原因是:子路由不需要等待父路由数据时,串行执行会增加总延迟。

但是,路由器不会自动把父 loader 的返回值作为子 loader 的参数。若子路由确实依赖父级结果,有三种常见选择:

  1. 子路由重新按 ID 读取所需数据;
  2. 将共享数据放到更高层并通过 useRouteLoaderData 读取;
  3. 在服务端或 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 的典型步骤是:

  1. request.formData() 读取提交内容;
  2. 在服务端或 API 层执行校验与写操作;
  3. 校验失败时返回结构化结果;
  4. 成功时返回数据或 redirect
  5. 成功的 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;路由器也支持 putpatchdelete 等方法,但后端语义和服务端实现仍需自行保证。

4.3 redirect 不等同于组件中的导航

在 action 中:

return redirect(`/projects/${projectId}`);

表示 action 的结果是一次路由重定向。路由器会停止当前结果的正常渲染流程,执行新的导航。

它与组件中调用:

navigate(`/projects/${projectId}`);

的差别在于,前者发生在写操作完成的控制流中。这样可以避免组件先根据旧状态渲染,再由组件额外决定是否跳转。

不要在 action 中返回 HTTP Response 后又期待 React Router 自动把它当作重定向,除非该响应明确表示重定向。最清晰的写法是直接使用 redirect()


五、useNavigationuseActionDatauseFetcher

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 还与路由数据生命周期相连,可以捕获 loaderaction 的错误。

因此二者不是完全互相替代的关系:

  • 路由数据错误应优先由 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 返回成功,但页面仍显示旧数据,重点检查:

  1. action 是否真的修改了服务端数据;
  2. action 是否返回了错误结果而不是成功结果;
  3. shouldRevalidate 是否错误地返回 false
  4. loader 是否读取了正确的路径参数;
  5. API 是否因为缓存、事务延迟或读写分离而返回旧数据;
  6. 页面是否实际读取的是 Redux 或组件本地状态,而不是 useLoaderData

尤其要注意:useLoaderData 返回的是当前路由数据快照。action 完成后,路由器更新该路由的数据,组件会重新渲染;但组件中另行复制出的本地状态不会自动同步:

const project = useLoaderData() as Project;
const [tasks, setTasks] = useState(project.tasks);

之后 loader 更新 project.taskstasks 仍可能保留旧值。除非有明确理由,不要把 loader 数据复制到独立状态中。


十四、如何划分 Loader、Action、组件状态与全局状态

可以用数据的来源和生命周期作判断:

数据类型 更适合的位置
由 URL 参数决定的页面数据 路由 loader
通过当前路由表单提交的写操作 路由 action
提交中的状态、action 返回的字段错误 useNavigationuseActionData
不改变 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 相关的数据生命周期,服务端负责最终的数据正确性与安全性。理解这条边界后,loaderaction、revalidation 和错误边界就不再是孤立 API,而是一套围绕导航和数据一致性组织起来的路由运行时机制。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。