在 React Router 中理清对话框
- 原文链接:programmingarehard.com/2026/05/06/...
- 原文作者:David Adams
对话框看起来很简单......直到你要决定何时何地加载数据、处理错误与成功反馈,并最终不可避免地祭出 useEffect。这篇文章里,我会拆解如何用 React Router 7 实现模态对话框:嵌套路由、loader 请求优化、反馈提示、程序化关闭对话框,以及保留动画......而且全程 不使用任何 useEffect。
项目搭建
我会带你走一遍一个简单的演示应用。这个应用用来管理一个 Ollama 实例。Ollama 是一个开源工具,让你可以在自己的电脑或服务器上本地运行大语言模型(LLM)。我们的应用会有一个路由列出已安装的模型,允许用户安装模型,也允许用户卸载模型。安装与卸载会放在对话框里完成。
我们会使用 framework 模式 下的 React Router、Tailwind,以及 shadcn 组件。
它大概长这样(Stackblitz 演示见这里):



第一次尝试
你(和我一样)的第一直觉,可能是在 app/routes/models.tsx 这个路由里直接放一两个 <Dialog>。安装模型 的对话框需要一些数据:我们得发请求拿到可用且未安装的模型列表。接下来很自然会想到 useFetcher。但这样一来,我们还需要一个 resource route 来提供可选模型列表;这里先假设它已经存在。models 路由组件可能长得像这样:
javascript
export async function loader() {
const models = await listInstalledModels()
return data({ models })
}
export default function ModelsPage({ loaderData }: Route.ComponentProps) {
const { models } = loaderData
const loadModelsFetcher = useFetcher()
const onInstallClick = () => {
loadModelsFetcher.load(href("/resources/models/installable"));
}
return (
<>
...page header...
<Dialog>
<DialogTrigger asChild onClick={onInstallClick}>
<Button type='button'>Install</Button>
</DialogTrigger>
<DialogContent>
<Form method="post">
....
<ModelSelect models={loadModelsFetcher.data ?? []} />
....
</installModelFetcher.Form>
</DialogContent>
</Dialog>
...models list...
</>
);
}
我们还需要一个 <Dialog> 来做 卸载模型 。它同样可以塞进这个 ModelsPage 路由组件,或者塞进 <ModelsTable> 组件里。随你选。有没有"唯一正确"的答案?🤷♂️ 但我们得在同一个路由的 action 里同时处理安装与卸载,这意味着得用表单字段区分意图:
ini
<input type="hidden" name="intent" value="install" />
// 或者
<input type="hidden" name="intent" value="uninstall" />
哦等等,我们还希望在提交成功后把对话框关掉。这意味着要能控制对话框的 open 状态。于是至少得加一个 useState。
嗯,那我们要怎么在 成功 提交后关闭对话框?我们可以 await useSubmit 或 fetcher.submit(),但它们返回的是 void,并不是提交结果。我们也可以乐观地直接关掉,但如果返回了错误,体验会很糟。我猜我们需要一个 useEffect 去"监听"actionData 再切换状态......?😩
scss
const [open, setOpen] = useState(false);
useEffect(() => {
if (actionData?.success) {
setOpen(false);
}
}, [actionData])
// ...
<Dialog open={open} onOpenChange={setOpen}>
...
</Dialog>
卸载模型 对话框还需要一些状态:对话框是否打开,以及要卸载哪个模型。
我们的 ModelsPage 路由很快就变复杂了:它要加载模型、安装、卸载、fetcher、对话框状态、还要同步提交状态。事情很多。
一定有更好的办法!
第二次尝试
与其把对话框直接写进 app/routes/models.tsx,不如让这个路由变成父路由:在模型表格后面渲染 <Outlet />,再为安装与卸载各建一个独立路由,让它们渲染进 <Outlet />。
bash
app/routes/models.tsx
app/routes/models.install.tsx
app/routes/models.uninstall.$name.tsx
只看路由文件,我们就能立刻更清楚地知道应用里"能做哪些事"。不过我以前这么做过,也踩过坑。好在 React Router 最近加了一些特性,可能会派上用场。我们来看看能走多远。
回到基础
在 app/routes/models.tsx 路由里,先把对话框相关的东西都撕掉:
Dialog相关 JSX- 对话框相关的
useState fetcheruseEffect- resource route
啊,是不是舒服多了?一个路由只做一件事:列出模型。
把 安装 按钮加回来,但让它链接到我们要新建的路由。还要确保这个路由里渲染了 <Outlet />。
ini
<Button className="mt-6" asChild>
<Link
to={href("/models/install")}
unstable_defaultShouldRevalidate={false}
preventScrollReset
>
<Download className="mr-1" />
Install
</Link>
</Button>
...models list...
<Outlet />
等等,<Link> 上那些 props 都是啥???
unstable_defaultShouldRevalidate 是我在 11 月给框架贡献的新能力。你可以 在这里读更多介绍。基本上,它允许你在某次导航中关闭对"当前仍激活路由"的重新校验(revalidation)。因为点击这个链接会发生导航,React Router 通常会重新校验所有激活路由。但这里我们不需要 app/root.tsx 的 loader(如果有的话)以及 app/routes/models.tsx 的 loader 重新跑一遍。与其在这些路由里各自实现 shouldRevalidate,不如在调用点直接改行为。用来优化网络请求时很方便。
preventScrollReset 就是字面意思:既然我们希望 UX 像是"在现有内容之上打开一个覆盖层式的对话框",就可以加这个 prop,让 React Router 别在用户导航时自动把页面滚到顶部。过渡会更顺滑。
我们也会把 卸载 按钮改成链接到对应路由。
ini
<Button variant="destructive" size="sm" asChild>
<Link
to={href("/models/uninstall/:name", model)}
unstable_defaultShouldRevalidate={false}
preventScrollReset
>
<Trash2 />
Uninstall
</Link>
</Button>
第一个对话框
我们会先实现卸载路由/对话框。既然它在独立路由里,就可以用朴素的 loader 和 action,而不必用 fetcher。
javascript
// 路径:app/routes/models.install.tsx
export async function loader() {
const models = await listUninstalledModels();
return data({ models });
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const name = formData.get("name") as string;
if (name.includes('qwen')) {
return data({ error: "Something went wrong :("}, { status: 400 })
}
const model = await searchModel(name);
await installModel(model);
throw redirect(href("/models"));
}
export default function ModelInstall({
loaderData,
actionData
}: Route.ComponentProps) {
const navigate = useNavigate();
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) {
navigate(href("/models"), {
unstable_defaultShouldRevalidate: false,
preventScrollReset: true,
replace: true,
});
}
}}
>
...
{actionData?.error ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Unable to install model</AlertTitle>
<AlertDescription>{actionData.error}</AlertDescription>
</Alert>
) : null}
<Form method="post" preventScrollReset replace>
...
</Form>
</Dialog>
);
}
这看起来就像任何别的路由一样,很好。朴素是好事。
因为要考虑用户关闭对话框,我们需要加 onOpenChange:监听关闭,然后导航回 /models 路由。模型安装成功后也会在 action 里导航离开。
给 <Form> 加 replace 是为了清理浏览器历史:我们不太希望对话框路由还能靠"后退"一步步走回去,所以用 replace 把 /models/install 这条栈记录替换成 /models。
关闭对话框时我们仍然不想触发路由重新校验,所以继续用 unstable_defaultShouldRevalidate;也不希望关闭时页面跳动,所以 preventScrollReset 也保留。
我这里硬编码了一个已知条件来模拟错误处理,这样你能看到错误展示长什么样。错误会直接显示在对话框里。
问题
到目前为止还不错,但还有两个点没覆盖到:
- 成功后的反馈提示
- 关闭对话框时的动画
成功反馈
提交成功后我们会导航回 /models 路由。路由会重新校验,所以新安装的模型会出现在列表里------但除此之外,没有明确反馈告诉用户"成功了"。列表里突然多一行其实很容易被忽略。我们加个 toast。
在 app/root.tsx 里加上 toast 容器组件:
xml
<body>
{children}
<Toaster position="top-center" />
<ScrollRestoration />
<Scripts />
</body>
接下来要想办法真正触发 toast。我们 可以 在导航里塞查询参数,但那并不是我们想要的 URL 形态。
csharp
const params = new URLSearchParams({
message: "Model installed successfully."
})
throw redirect(href("/models")} + `?${params}`);
👎
这很适合用 session 状态。我们新建一个文件来配置基于 cookie 的 session storage。
typescript
// 路径:app/session.server.ts
import { createCookieSessionStorage } from "react-router";
type SessionData = {
userId: string;
};
type SessionFlashData = {
toast: string;
};
const {
getSession: getSessionBase,
commitSession,
destroySession,
} = createCookieSessionStorage<SessionData, SessionFlashData>({
cookie: {
name: "___session",
httpOnly: true,
maxAge: 60,
path: "/",
sameSite: "lax",
secrets: ["s3cret1"],
secure: true,
},
});
async function getSession(request: Request) {
return getSessionBase(request.headers.get("Cookie"));
}
export { getSession, commitSession, destroySession };
这大体来自官方文档,但我写了一个更顺手的 getSession,并为 session 数据与 flash 数据补了类型。
Session 数据会在用户使用应用期间一直存在,比如当前用户详情。Flash session 数据是临时的:读一次就会被清掉。这正适合我们的场景。
现在可以在 action 里用它了:
csharp
// ...
await installModel(model);
const session = await getSession(request);
session.flash("toast", "Model installed successfully.");
throw redirect(href("/models"), {
headers: {
"Set-Cookie": await commitSession(session),
},
});
我们把消息 flash 进 session,并通过响应头 commit。
在 /models 路由消费它。先在 loader 里读出来:
javascript
// 路径:app/routes/models.tsx
export async function loader({ request }: Route.LoaderArgs) {
const models = await listUninstalledModels();
const session = await getSession(request)
const toast = session.get('toast')
return data({ models, toast }, {
headers: {
'Set-Cookie': await commitSession(session)
}
});
}
注意! 读完 flash 数据后,你 必须
commitsession,它才会真的被清掉!
好,现在数据从 session 里出来了,但这发生在服务端。客户端还得做点什么。在组件里写 useEffect?不要!
我们可以实现 clientLoader,在数据进入路由组件之前拦截它:
javascript
// 路径:app/routes/models.tsx
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
const result = await serverLoader();
if (result.toast) {
toast.success(result.toast);
}
return result;
}
组件仍然不会被 useEffect 以及任何 toast 相关逻辑"污染"。完美。
不喜欢 toast?也没问题。我们仍然把 toast 文案作为 loaderData 传给 /models 路由组件。你更喜欢的话,也可以直接在模型列表上方渲染它。
退场动画
现在要解决退场动画。问题是:当用户从对话框路由导航回父级 /models 路由时,对话框相关的 DOM 会 立刻 被移除。对话框会瞬间消失。不像打开时那样,有一个顺滑的淡出/缩放退场。
对话框出现时你能做动画,因为......它确实存在于 DOM 里。反过来就麻烦了:元素已经不存在了,还要给它做动画......通常很难。平常用 shadcn 的 Dialog 时,进/退场动画会在底层替你处理,你基本不用操心。它会做一些"等到动画结束再卸载 DOM"的技巧。
一旦我们把对话框绑到路由上,就没有这种"等待"了。元素会立刻被移除。
幸运的是,浏览器实现了一个 React Router 也支持的能力,我们可以借它一用。
View Transitions(视图过渡)
View Transitions 能让浏览器在不同 DOM 状态之间顺滑过渡。对我们的场景来说,就是"对话框存在"到"对话框消失"。
我们需要给要做动画的元素打上目标。在 shadcn 的 dialog 组件文件里更新 overlay 与 content:
ini
// 路径:app/components/ui/dialog.tsx
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
style={{ viewTransitionName: "dialog-overlay" }}
// ...
<DialogPrimitive.Content
data-slot="dialog-content"
style={{ viewTransitionName: "dialog-content" }}
我们还需要去掉 overlay 上的模糊效果;在这里它似乎和 view transitions 不太合拍。
css
-- bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs
++ bg-black/40 duration-100
接下来要写一点 view transition 的 CSS,让它尽量贴合这些组件上原有的动画 class。
css
/* 路径:app/app.css */
@keyframes dialog-content-in {
from {
opacity: 0;
transform: scale(0.95);
}
}
@keyframes dialog-content-out {
to {
opacity: 0;
transform: scale(0.95);
}
}
::view-transition-old(dialog-content) {
animation: dialog-content-out 100ms ease forwards;
}
::view-transition-new(dialog-content) {
animation: dialog-content-in 100ms ease forwards;
}
::view-transition-old(dialog-overlay),
::view-transition-new(dialog-overlay) {
animation-duration: 100ms;
}
最后一步:在导航时真正启动 view transition。回到 app/routes/models.install.tsx 组件:
php
<Dialog
open
onOpenChange={(open) => {
if (!open) {
navigate(href("/models"), {
unstable_defaultShouldRevalidate: false,
preventScrollReset: true,
replace: true,
viewTransition: true,
});
}
}}
>
...
<Form method="post" preventScrollReset replace>
有两种情况需要设置 viewTransition 选项:它们都会导航回 /models 路由,并让对话框从 DOM 中消失。
成功!现在对话框退场动画能和"进入"动画对上了。
我可能说了大话......
如果你跟着做,可能会发现:一旦返回错误,对话框会有点"弹跳"。这是因为整体包在 view transition 里,再叠加我们的 CSS:DOM 变化(错误 UI 渲染)时也会触发淡入淡出与缩放。看起来就像对话框乐观地要关掉了,但错误又阻止了它真正卸载,于是它又弹回来。
我觉得这是可接受的取舍;但如果你对话框经常返回错误,可能就不会同意。我们可以引入 useEffect 来解决。
首先,action 需要改成始终返回 action data,而不是 throw redirect。
csharp
// 路径:app/routes/models.install.tsx
// ...
const session = await getSession(request);
session.flash("toast", "Model installed successfully.");
return data(
{ error: false },
{
headers: {
"Set-Cookie": await commitSession(session),
},
},
);
接下来,从 <Form> 上移除 viewTransition prop,这样错误更新 DOM 时就不会触发动画。
ini
// 路径:app/routes/models.install.tsx
<Form method="post" preventScrollReset replace>
最后,加一个 useEffect:监听 error 明确为 false 时,再用带 viewTransition 的选项去导航。
php
// 路径:app/routes/models.install.tsx
useEffect(() => {
if (actionData?.error === false) {
navigate(href("/models"), {
unstable_defaultShouldRevalidate: false,
preventScrollReset: true,
replace: true,
viewTransition: true,
});
}
}, [actionData]);
这能避免错误场景下的"弹跳",对话框仍然与路由同步,但我们又回到了那个令人畏惧的 useEffect。取舍。⚖️
收尾
一开始那团 useEffect、fetcher 和对话框状态,现在变成了相当直白的东西:每个对话框都在自己的路由里,职责单一。
真正的收获不只是"避开 useEffect",而是代码终于更贴近我们思考应用的方式:想知道能做哪些操作?去看路由。
View transitions 解决了让"基于路由的对话框"一直显得很糙的退场动画问题。再叠上 unstable_defaultShouldRevalidate 做性能优化、用 flash session 做反馈,这套模式终于变得顺手。
我跟对话框实现搏斗了很多年,试过无数种想法。这是第一次让我觉得真的 好用:框架扛重活,我们只需要把现有工具用到位。
如果你也在 React Router 应用里被对话框状态折磨,不妨试试这个模式。完整演示代码在 GitHub 上可取,如果你想看所有细节如何串起来。