React Router Pending UI:构建导航加载与表单提交期间的即时反馈界面
导航到一个新路由、向 action 提交数据,都需要等待数据返回,用户在这段等待期内会感到"卡住"。本指南基于 React Router 官方文档中的 Pending UI 指南(及对应的 Data Mode 入口文档),系统讲解如何借助 useNavigation、NavLink 渲染属性与 useFetcher,在全局导航、局部链接与表单提交三种场景下实现加载中(Pending)与乐观(Optimistic)UI。读完本文,你将掌握判断"正在导航/正在提交"的标准写法,并能在数据尚未落库时就地渲染预期的界面状态。
核心思想:把"等待中的 UI"交给应用代码
React Router 在页面数据就绪前不会渲染新页面——当用户导航到新 URL 时,目标页面的 loader 会在下一帧渲染前被完整执行(参见 Data Loading 中"loaders 在路由组件渲染前被调用"的说明);同理,action 完成后所有 loader 数据会自动重新校验以保持 UI 与数据同步(参见 Actions)。
因此,React Router 本身并不阻塞交互,而是把"等待期间如何反馈"完全交给应用代码负责:
When the user navigates to a new route, or submits data to an action, the UI should immediately respond to the user's actions with a pending or optimistic state. Application code is responsible for this.
框架模式与 Data 模式(createBrowserRouter + RouterProvider)在此处的 API 完全一致——Data Mode 下的 Pending UI 文档 明确指出:"Pending UI is the same as Framework Mode",并指向 Framework 模式指南。而根据 Modes 模式选择文档 中的 API 可用性表,useNavigation、useFetcher、NavLink 的 isPending 能力均同时适用于 Framework 与 Data 模式(Declarative 模式只提供基础的 NavLink,不具备 pending 状态)。如果你尚未选择模式,可先阅读 Picking a Mode。
全局 Pending 导航:useNavigation 驱动顶部加载条
最典型的 pending UI 是"全局加载指示器":无论用户点哪个链接进入哪个页面,只要数据加载尚未完成,就在根布局顶部显示一个旋转或进度条。
判断导航是否在进行
useNavigation 返回当前的 Navigation 对象,其 state 分为三种(定义见 NavigationStates):
| 状态 | 含义 | 关键字段 |
|---|---|---|
idle |
空闲,无进行中的导航 | location 为 undefined,所有 form* 字段亦为 undefined |
loading |
正在加载目标路由数据 | location 指向新位置;若本次加载源自表单 GET 提交,则 formAction、formData 等亦可用 |
submitting |
正在提交数据(如 POST 表单) | location 与 formMethod、formAction、formData 均已就位 |
实现细节:从 源码 hooks.tsx 可以看出,
useNavigation内部从 router state 中取出navigation并剔除matches与historyAction后以useMemo返回;navigation.state为"idle"时不会触发多余渲染,因此可以直接在根组件无条件调用。
在框架模式的路由模块根布局(root.tsx)中,经典写法是用"是否有 navigation.location"作为导航中的判定,因为 idle 时 location 一定为 undefined:
import { useNavigation } from "react-router";
export default function Root() {
const navigation = useNavigation();
const isNavigating = Boolean(navigation.location);
return (
<html>
<body>
{isNavigating && <GlobalSpinner />}
<Outlet />
</body>
</html>
);
}
这段代码的语义是:只要存在一个进行中的导航(无论 loading 还是 submitting),navigation.location 就非空,于是渲染 <GlobalSpinner />;导航结束后自动消失。它放在 <Outlet /> 外层,因此对所有路由生效。
局部 Pending 导航:NavLink 渲染属性实现链接级反馈
当用户点击侧边栏/导航栏中的链接时,等待的新页面还未出现,此时被点击的那个链接应当立刻处于 pending 视觉态(例如变灰或打转),让用户明确感知"这个导航被接受了,正在加载"。
NavLink 的三个 prop——children、className、style——都支持传入接收 pending 状态的渲染函数。除了框架模式指南中展示的 children 与 style 两种用法,className 也可用于批量切换 CSS 类:
import { NavLink } from "react-router";
function Navbar() {
return (
<nav>
<NavLink to="/home">
{({ isPending }) => (
<span>Home {isPending && <Spinner />}</span>
)}
</NavLink>
<NavLink
to="/about"
style={({ isPending }) => ({
color: isPending ? "gray" : "black",
})}
>
About
</NavLink>
<NavLink
to="/settings"
className={({ isPending }) =>
isPending ? "pending" : "normal"
}
>
Settings
</NavLink>
</nav>
);
}
要点:
- 只有
data/framework模式的导航才具备 pending 语义,isPending在 Declarative 模式下不生效(见 modes.md 可用性表); - pending 状态仅作用于触发本次导航的链接本身,不会波及整个导航栏;
- 若配合
<Link prefetch>/discover等能力,isPending的触发时机还与路由数据预取策略相关,属于 Framework 模式专属特性。
Pending 表单提交:按钮在等待期显示"提交中..."
表单提交有两种形态:普通 <Form>(引发一次全局导航)与 <fetcher.Form>(独立状态、不引发导航)。两者的 pending 状态来源不同,需要分别处理。
推荐:fetcher.Form + fetcher.state
由于 useFetcher 返回的 fetcher 拥有独立于全局导航的状态(提交/加载/空闲各自管理),它是实现按钮 pending 态最简单的方式——详见 actions.md 中 "Calling actions with a fetcher" 的"不产生新历史记录条目的提交"说明:
import { useFetcher } from "react-router";
function NewProjectForm() {
const fetcher = useFetcher();
return (
<fetcher.Form method="post">
<input type="text" name="title" />
<button type="submit">
{fetcher.state !== "idle"
? "Submitting..."
: "Submit"}
</button>
</fetcher.Form>
);
}
fetcher.state 三态定义参见 FetcherStates:
idle:未在调用任何 loader 或 action;loading:正在通过fetcher.load()拉取数据;submitting:正在通过fetcher.Form/fetcher.submit提交(此时fetcher.formData已可用)。
因为 fetcher 不触发全局导航,多个 fetcher 可并行提交,各自的按钮互不干扰。
普通 <Form>:读取 useNavigation 的 form 字段
不使用 fetcher 的普通表单提交会引发一次全局导航,pending 信息位于 useNavigation 上。此时用 navigation.state 判断过于宽泛(同一时刻只可能有一个全局导航),因此应结合 navigation.formAction 精确判断"本次提交是否发往当前表单的 action":
import { useNavigation, Form } from "react-router";
function NewProjectForm() {
const navigation = useNavigation();
return (
<Form method="post" action="/projects/new">
<input type="text" name="title" />
<button type="submit">
{navigation.formAction === "/projects/new"
? "Submitting..."
: "Submit"}
</button>
</Form>
);
}
当用户提交本表单后,navigation 进入 submitting(随后转入 loading),navigation.formAction 会保持为 /projects/new,直到导航完成回到 idle。这避免了页面上其他导航(如点击链接跳转)误触发本按钮的"提交中"文案。
乐观 UI(Optimistic UI):用 fetcher.formData 抢占渲染
某些交互的"目标终态"在提交那一刻就已确定。例如切换任务完成状态:只要用户点了按钮,其意图(把任务标记为完成/未完成)即已知,不需要等服务端返回就能更新勾选框。这种把预期结果先渲染出来、事后由服务端确认的技法就是乐观 UI,能带来"零等待"的即时反馈。
依据:submitting 状态下的 fetcher 会携带本次提交的完整 FormData(见 FetcherStates.Submitting)。因此可以用"是否存在 fetcher.formData"来区分"正在提交中的值"与"服务端已确认的值":
function Task({ task }) {
const fetcher = useFetcher();
let isComplete = task.status === "complete";
if (fetcher.formData) {
isComplete =
fetcher.formData.get("status") === "complete";
}
return (
<div>
<div>{task.title}</div>
<fetcher.Form method="post">
<button
name="status"
value={isComplete ? "incomplete" : "complete"}
>
{isComplete ? "Mark Incomplete" : "Mark Complete"}
</button>
</fetcher.Form>
</div>
);
}
执行流程拆解:
- 初始渲染:
fetcher.formData为undefined,isComplete取task.status; - 用户点击:表单以 POST 提交到对应 action,fetcher 进入
submitting,fetcher.formData.get("status")即用户刚选择的布尔值,界面立刻切换为勾选/取消状态并显示反向文案; - 提交期间按钮再次被点击时,
value已基于乐观值反转,操作可连续叠加; - action 完成后数据重新校验(revalidation),若服务端结果与乐观值一致,UI 保持不动;若不一致,则以服务端数据为准自动校正。
在 Data 模式下使用同样的 pending API
如果你的应用使用 createBrowserRouter + <RouterProvider> 构建(Data 模式,详见 Custom Framework 的客户端渲染小节),useNavigation、useFetcher、NavLink 的 pending 渲染属性均可直接使用,且行为与框架模式一致——Data 模式的 pending-ui.md 正是这样说明的。框架模式仅额外提供了类型安全的 href、路由模块 API、代码分割与 SSR/SPA/SSG 渲染策略(见 modes.md),pending 状态的数据源在底层并无区别。
小结与可深入路径
- 全局导航等待 →
useNavigation().location; - 链接级局部反馈 →
NavLink的children/className/style渲染函数 +isPending; - fetcher 表单按钮 →
fetcher.state !== "idle"; - 普通表单按钮 →
navigation.formAction === "/path"; - 即时体验 → 用
fetcher.formData覆盖现有数据实现乐观 UI。
进一步探索可参考:源码层面见 NavigationStates / FetcherStates 的类型定义 与 useNavigation 实现;API 层面见 useNavigation、useFetcher、NavLink 文档;完整能力矩阵见 modes.md。若想了解服务端渲染下的导航与数据加载流程,可继续阅读 Data Mode 自定义框架 与 Testing。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00