
一句话:数据路由(
createBrowserRouter)提供loader(渲染前取数据) 与action(表单提交),配合useLoaderData/useActionData/useNavigation把「数据获取 + 提交状态 + 错误处理」统一到路由层。
一、loader:渲染前取数据
// router.tsx
const router = createBrowserRouter([
{
path: 'users/:id',
element: <UserDetail />,
loader: async ({ params, request, signal }) => {
// ⭐ loader 的上下文参数
return fetchUser(params.id!, { signal });
},
},
]);
| loader 参数 | 说明 |
|---|---|
params |
路由参数(同 useParams) |
request |
一个 Request 对象(可读 url、method、body) |
signal |
AbortSignal——导航切换时会被 abort(要传给 fetch) |
context |
路由上下文(createBrowserRouter 的第二个参数可提供) |
// 组件里同步读取(不需要 loading 状态)
import { useLoaderData } from 'react-router-dom';
function UserDetail() {
const user = useLoaderData() as User; // ⭐ 数据已经就绪
return <div>{user.name}</div>;
}
// ✅ 读查询参数(loader 里没有 useSearchParams,要用 request.url)
loader: async ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') ?? '1');
const keyword = url.searchParams.get('q') ?? '';
return fetchUsers({ page, keyword });
}
// ✅ 并行请求(避免瀑布)
loader: async ({ params }) => {
const [user, orders] = await Promise.all([
fetchUser(params.id!),
fetchOrders(params.id!),
]);
return { user, orders };
}
⭐ loader 的核心价值:
① 数据在「渲染前」就绪 → 组件里不需要 loading 状态、没有骨架闪烁
② 请求与「路由切换」绑定 → 进入页面即开始加载
③ 可并行发起多个请求 → 避免「组件内 useEffect 的瀑布」
⚠️ 代价:导航会「等数据就绪」才完成 → 需要用 useNavigation 显示进度
二、action:表单提交
// ✅ action 处理写操作
{
path: 'todos/new',
element: <NewTodo />,
action: async ({ request }) => {
const formData = await request.formData();
const text = formData.get('text') as string;
if (!text?.trim()) {
return { error: '请输入内容' }; // ⭐ 返回错误(不跳转)
}
await createTodo({ text });
return redirect('/todos'); // ⭐ 成功后重定向
},
}
// ✅ 组件里用 React Router 的 <Form>(提交时自动调用 action)
import { Form, useActionData, useNavigation, useSubmit } from 'react-router-dom';
function NewTodo() {
const actionData = useActionData() as { error?: string } | undefined;
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting'; // ⭐ 提交中
return (
<Form method="post">
<input name="text" required />
<button disabled={isSubmitting}>{isSubmitting ? '提交中…' : '添加'}</button>
{actionData?.error && <p role="alert">{actionData.error}</p>}
</Form>
);
}
| 组件 | 说明 |
|---|---|
<Form> |
React Router 的表单(提交时走 action,不刷新页面) |
<Form method="get"> |
提交时不走 action,而是「更新 URL 查询参数」(适合搜索表单) |
useActionData() |
读取 action 的返回值 |
useSubmit() |
命令式提交表单(如「保存」按钮在表单外) |
useFetcher() |
不触发导航的提交(如「点赞」、行内编辑) |
// ✅ method="get" 的用法:搜索表单(把输入变成查询参数)
<Form method="get" action="/search">
<input name="q" defaultValue={initialKeyword} />
<button>搜索</button>
</Form>
// 提交后 URL 变成 /search?q=xxx(不需要自己写 setSearchParams)
// ✅ useFetcher:不导航的表单提交(如列表里的「删除」「点赞」)
import { useFetcher } from 'react-router-dom';
function TodoItem({ todo }: { todo: Todo }) {
const fetcher = useFetcher();
return (
<fetcher.Form method="post" action={`/todos/${todo.id}/toggle`}>
<button disabled={fetcher.state !== 'idle'}>
{todo.done ? '✅' : '⬜'} {todo.text}
</button>
</fetcher.Form>
);
}
// ⭐ 提交不会触发路由跳转,但仍会走 action 并重新执行相关 loader
⭐ action 的隐藏能力:
提交成功后,React Router 会【自动重新执行该路由的 loader】
→ 列表数据自动刷新(不需要手动 invalidate)
import { useNavigation } from 'react-router-dom';
function Layout() {
const navigation = useNavigation();
return (
<>
{navigation.state !== 'idle' && <TopProgressBar />}
<Outlet />
</>
);
}
navigation.state |
含义 |
|---|---|
'idle' |
空闲 |
'loading' |
正在「加载目标路由的 loader」 |
'submitting' |
正在「执行 action」 |
// ✅ 更细的信息
const navigation = useNavigation();
navigation.location // 正在导航到的位置
navigation.formData // 正在提交的表单数据(submitting 时)
navigation.formAction // 提交的目标 action
navigation.formMethod // 提交方法(get/post)
四、重定向与错误
// ① 重定向
import { redirect } from 'react-router-dom';
loader: async () => {
const user = await getCurrentUser();
if (!user) throw redirect('/login'); // ⭐ loader 里重定向
return user;
}
action: async ({ request }) => {
await save(await request.formData());
return redirect('/list'); // ⭐ action 返回值重定向
}
// ② 抛「路由级错误」(交给最近的 errorElement)
loader: async ({ params }) => {
const user = await fetchUser(params.id!);
if (!user) {
throw new Response('Not Found', { status: 404 }); // ⭐ 必须是 Response
}
return user;
}
// ③ 读取错误
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
function UserError() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
// ⭐ 处理 Response 类错误(有 status / statusText / data)
if (error.status === 404) return <NotFound />;
if (error.status === 401) return <Navigate to="/login" replace />;
return <div>{error.status} {error.statusText}</div>;
}
// ⭐ 其它错误(如代码里 throw new Error)
return <div>出错了:{error instanceof Error ? error.message : String(error)}</div>;
}
⚠️ 关键区别:
· loader/action 里「返回」一个 Response → React Router 会【渲染】它(用于「代理请求」场景)
· loader/action 里「throw」一个 Response → 交给【errorElement】(用于「错误状态」)
· throw 非 Response 的错误 → errorElement 收到 Error 对象(用 isRouteErrorResponse 判断会失败)
五、与 TanStack Query 的取舍
| 维度 | 数据路由(loader/action) |
TanStack Query |
|---|---|---|
| 数据就绪时机 | 渲染前(无骨架闪烁) | 渲染后(需要 loading 态) |
| 与路由绑定 | ✅(导航即加载) | ⚠️(自己用 key 关联) |
| 缓存策略 | 由 loader + revalidate 控制 | 精细(staleTime/gcTime/invalidate) |
| 重试 | 自己实现 | ✅ 内置 |
| 窗口聚焦重验证 | ❌ | ✅ |
| 无限滚动/分页 | 较麻烦 | ✅ useInfiniteQuery |
| 乐观更新 | 需自己写 | ✅ onMutate |
| 导航状态 | ✅ useNavigation |
⚠️ 自己管理 |
| 错误边界 | ✅ errorElement |
⚠️ 自己接 |
| 生态与工具 | 官方路由内置 | 生态成熟、DevTools |
三种组合策略:
① 只用 loader/action
适合「数据获取与路由强绑定」的应用(后台系统、内容站点)
→ 简单统一,但缓存能力弱
② 只用 TanStack Query
适合「数据频繁交互、需要精细缓存」的应用
→ 失去「渲染前预取」的体验
③ 桥接:loader 里预热 Query 缓存 ⭐(推荐给已用 Query 的项目)
loader: ({ params }) =>
queryClient.ensureQueryData({
queryKey: ['user', params.id],
queryFn: () => fetchUser(params.id!),
})
→ 组件里仍用 useQuery(命中已预热的缓存,不重复请求)
→ 既有「渲染前预取」,又有 Query 的缓存能力
// ③ 的完整示例
const queryClient = new QueryClient();
const router = createBrowserRouter([
{
path: 'users/:id',
element: <UserDetail />,
loader: ({ params }) =>
queryClient.ensureQueryData({
queryKey: ['user', params.id],
queryFn: () => fetchUser(params.id!),
}),
},
]);
function UserDetail() {
const { id } = useParams();
// ⭐ 数据已被 loader 预热 → 首次渲染就有数据(不会闪骨架)
const { data, isLoading } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id!),
});
if (isLoading) return <Skeleton />; // 仅「后续导航」时可能短暂出现
return <div>{data?.name}</div>;
}
六、常见坑
// ① loader 里没把 signal 传给 fetch(导航切换时请求不会取消)
loader: async ({ params, signal }) => {
return fetch(`/api/users/${params.id}`, { signal }); // ✅
}
// ② loader 返回「不可序列化」的东西(类实例、函数、Date 之外的复杂对象)
// ⚠️ 如果用了「服务端渲染 + 流式传输」,返回值需要可序列化
// ③ 用 <form> 而不是 <Form>(action 不生效)
<form method="post">...</form> // ⚠️ 浏览器原生提交
<Form method="post">...</Form> // ✅
// ④ 在 action 里「只返回数据」但组件没读 useActionData
// ⑤ throw 了普通 Error 却用 isRouteErrorResponse 判断(拿不到 status)
throw new Error('404'); // ⚠️ 没有 status
throw new Response('Not Found', { status: 404 }); // ✅
// ⑥ loader 里做「副作用」(它是「取数据」的,不该改数据)
// ⑦ 把「所有数据」都在 loader 里请求(首屏阻塞时间变长)
// ⑧ 忘记「loader 会在每次导航时重新执行」(包括查询参数变化)
// ⑨ 在 loader 里读 window/localStorage(SSR 时会报错)
loader: () => localStorage.getItem('token') // ⚠️ 服务端没有 window
// ✅ 用 cookie(通过 request.headers)或「注入上下文」
// ⑩ 用 loader 但忘了配置 errorElement(错误时显示默认错误页)
// ⑪ 期待 loader 的数据「自动缓存」(它不缓存,每次导航都重新执行)
// ⑫ 在 useActionData 里读「上一次」的提交结果(导航后会清空)
// ⑬ useFetcher 与 useSubmit 混用不当(前者不导航,后者会)
// ⑭ loader 里 await 多个请求「串行」而不是并行(瀑布)
// ⑭ 的修复对比
// ❌ 串行(慢)
const user = await fetchUser(id);
const orders = await fetchOrders(id);
// ✅ 并行(快)
const [user, orders] = await Promise.all([fetchUser(id), fetchOrders(id)]);
七、面试延伸
- 「
loader是什么?它的执行时机是什么?」
loader是数据路由(createBrowserRouter)提供的「在路由匹配后、组件渲染前执行的函数」,用于取数据。它的参数包括params(路由参数)、request(Request对象,可读 URL 与查询参数)、signal(AbortSignal,导航切换时会被 abort,应传给fetch)。组件里用useLoaderData()同步读取,因此不需要 loading 状态、没有骨架闪烁。代价是「导航会等数据就绪才完成」,需要用useNavigation显示进度。 -
「
loader和组件里的useEffect请求有什么区别?」
① 时机——loader在渲染前执行(数据就绪才渲染);useEffect在渲染后执行(先渲染一次无数据的状态)。② 体验——loader避免了「骨架闪烁 + 请求瀑布」;useEffect需要自己管理loading/error与竞态。③ 错误处理——loader抛错会交给errorElement;useEffect里要自己 try/catch。④ 代价——loader会阻塞导航(等数据),数据慢时用户会觉得「点了没反应」。 -
「
action是做什么的?」
action处理「写操作」(表单提交、数据变更),与loader对应。它的四个特点:① 与 React Router 的<Form method="post">配合(提交时自动调用对应路由的action);② 可以返回数据(用useActionData读取,适合返回校验错误)或redirect()(提交成功后跳转);③ 提交过程中可以用useNavigation().state === 'submitting'显示「提交中」;④ 提交成功后 React Router 会自动重新执行该路由的loader,从而刷新列表数据(不需要手动失效)。 -
「
useFetcher和useSubmit有什么区别?」
useSubmit是「命令式提交表单」——它会触发导航(因为提交目标是一个路由的action);useFetcher是「不触发导航的提交」——它也会走action并重新执行相关的loader,但不会改变当前 URL 或组件树(所以适合「列表里的删除按钮」「点赞」「行内编辑」这类「提交后还要留在当前页面」的场景)。 -
「
loader的数据会自动缓存吗?」
不会。loader在每次导航(包括「查询参数变化」「同路由重复进入」)时都会重新执行,React Router 本身不提供「时间维度的缓存」(staleTime那类)。如果需要缓存,应该:① 在loader里调用「你自己的缓存层」(如queryClient.ensureQueryData——它会在缓存新鲜时直接返回);② 或改用 TanStack Query 在组件里取数(但要接受「渲染后才有数据」)。这也是「数据路由要与查询库桥接」的原因。 -
「
loader里怎么读查询参数?」
用request.url自己解析:const url = new URL(request.url); url.searchParams.get('page')(loader里不能用useSearchParams,因为它是 Hook)。这也是数据路由的一个特点——「数据获取是路由层的事」,所以查询参数通过Request传递。如果参数变化需要重新执行loader,React Router 会自动处理(因为 URL 变化 = 导航)。 -
「
throw Response和return Response有什么区别?」
在loader/action里:①throw new Response(...)—— 表示「这是一个错误状态」,会交给最近的errorElement处理(用useRouteError+isRouteErrorResponse读取status/statusText/data);②return new Response(...)—— 表示「把响应直接返回给浏览器」,React Router 会把它作为结果处理(典型用途是「代理一个下载请求」「返回非 HTML 内容」)。判断错误时用 throw,转发响应时用 return。 -
「数据路由的
loader和 TanStack Query 怎么选/结合?」
三种策略:① 只用loader/action—— 适合「数据获取与路由强绑定」的应用,简单统一但缓存能力弱;② 只用 TanStack Query —— 适合「数据交互频繁、需要精细缓存(staleTime/失效/重试/乐观更新/无限滚动)」的应用,但失去「渲染前预取」的体验;③ 桥接(推荐给已用 Query 的项目) —— 在loader里调用queryClient.ensureQueryData({ queryKey, queryFn })预热缓存,组件里仍用useQuery(命中缓存、不重复请求),这样既有「渲染前数据就绪」又有 Query 的缓存能力。
一句话速记
数据路由提供
loader(渲染前取数据,参数params/request/signal,用useLoaderData同步读取,不缓存、每次导航都执行)与action(配合<Form method="post">,可用useActionData读返回、redirect()跳转、useNavigation看loading/submitting,成功后自动重跑 loader);useFetcher提交不导航(用于列表内操作);throw new Response()交给errorElement(配useRouteError+isRouteErrorResponse),return Response是转发响应;loader里读查询参数用request.url、多请求要Promise.all并行、把signal传给fetch;与 TanStack Query 可「二选一」或「ensureQueryData桥接」(推荐)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!