NextJS13+Shadcn组件条件渲染出现Hydration错误求助
NextJS13 + Shadcn 书签编辑组件 Hydration 问题排查
我在使用NextJS13搭配Shadcn开发书签编辑组件时,条件渲染环节出现了Hydration问题(错误截图:
)。尝试了多种条件渲染写法,甚至拆分组件,但始终无法定位问题根源,想咨询是否遗漏了相关开发规范?
问题代码
"use client"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import URLInput from "@/components/url-input"; import { useEffect, useState } from "react"; import { useEditBookmark } from "@/hooks/use-edit-bookmark"; import { useBookmark } from "@/hooks/use-get-bookmark"; export default function BookmarkEdit({ params: { bookmarkId }, }: { params: { bookmarkId: string }; }) { const { bookmark, isLoading, isFetching, error } = useBookmark( parseInt(bookmarkId) ); const [name, setName] = useState(""); const [url, setURL] = useState(""); const [labels, setLabels] = useState(""); const { edit } = useEditBookmark(); useEffect(() => { if (bookmark) { setName(bookmark.name); setURL(bookmark.url); setLabels(bookmark.labels.join(",")); } }, [bookmark]); if (isLoading || isFetching) { return ( <Dialog open={true}> <DialogContent className="w-full flex items-center"> <div className="inline-block h-6 w-6 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" role="status" ></div> <span className="ml-2">Loading...</span> </DialogContent> </Dialog> ); } if (!bookmark) { return ( <Dialog open={true}> <DialogContent className="w-full"> <span className="ml-2">Bookmark not found</span> </DialogContent> </Dialog> ); } const handleEditBookmark = async ({ name, url, labels, }: { name: string; url: string; labels: string; }) => { edit({ id: bookmark.id, name, url, labels: labels.split(",").map((label) => label.trim()), }); }; return ( <Dialog open={true}> <DialogContent className="w-full"> <DialogHeader> <DialogTitle>Add Bookmark</DialogTitle> <DialogDescription> The only required field is actually the URL </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> <URLInput url={url} setURL={setURL} setLabels={setLabels} /> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="name" className="text-right"> Name </Label> <Input id="name" placeholder="Something..." className="col-span-3" onChange={(e) => setName(e.target.value)} value={name} /> </div> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="labels" className="text-right"> Labels </Label> <Input id="labels" placeholder="work,product" className="col-span-3" onChange={(e) => setLabels(e.target.value)} value={labels} /> </div> </div> <DialogFooter> <Button onClick={() => handleEditBookmark({ name, url, labels })}> Edit bookmark </Button> </DialogFooter> </DialogContent> </Dialog> ); }
问题分析与解决方案
1. 服务端与客户端DOM结构不匹配
你当前的三个渲染分支返回了完全独立的Dialog组件,服务端渲染时可能输出Loading状态的DOM,而客户端hydrate时状态切换会导致DOM结构突变,触发Hydration错误。
修复方案:统一外层Dialog结构,只替换内部内容,保证服务端和客户端渲染的DOM骨架一致:
// 替换原有的三个独立return,改为统一结构 return ( <Dialog open={true}> <DialogContent className="w-full"> {isLoading || isFetching ? ( <div className="flex items-center"> <div className="inline-block h-6 w-6 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" role="status" ></div> <span className="ml-2">Loading...</span> </div> ) : !bookmark ? ( <span className="ml-2">Bookmark not found</span> ) : ( <> {/* 原编辑表单内容 */} </> )} </DialogContent> </Dialog> );
2. 初始状态与服务端数据不同步
你的表单state初始值为空字符串,服务端如果预取到了bookmark数据,客户端hydrate时会先渲染空表单,再通过useEffect更新内容,导致DOM内容不匹配。
修复方案:直接用bookmark的初始值初始化state,减少不必要的异步更新:
const [name, setName] = useState(bookmark?.name || ""); const [url, setURL] = useState(bookmark?.url || ""); const [labels, setLabels] = useState(bookmark?.labels?.join(",") || "");
3. 额外优化点
- 把
handleEditBookmark函数简化,直接在Button的onClick中调用edit,减少函数嵌套 - 把DialogTitle从"Add Bookmark"改为"Edit Bookmark",符合组件功能定位
修改后的完整代码
"use client"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import URLInput from "@/components/url-input"; import { useEffect, useState } from "react"; import { useEditBookmark } from "@/hooks/use-edit-bookmark"; import { useBookmark } from "@/hooks/use-get-bookmark"; export default function BookmarkEdit({ params: { bookmarkId }, }: { params: { bookmarkId: string }; }) { const { bookmark, isLoading, isFetching, error } = useBookmark( parseInt(bookmarkId) ); const [name, setName] = useState(bookmark?.name || ""); const [url, setURL] = useState(bookmark?.url || ""); const [labels, setLabels] = useState(bookmark?.labels?.join(",") || ""); const { edit } = useEditBookmark(); useEffect(() => { if (bookmark) { setName(bookmark.name); setURL(bookmark.url); setLabels(bookmark.labels.join(",")); } }, [bookmark]); return ( <Dialog open={true}> <DialogContent className="w-full"> {isLoading || isFetching ? ( <div className="flex items-center"> <div className="inline-block h-6 w-6 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" role="status" ></div> <span className="ml-2">Loading...</span> </div> ) : !bookmark ? ( <span className="ml-2">Bookmark not found</span> ) : ( <> <DialogHeader> <DialogTitle>Edit Bookmark</DialogTitle> <DialogDescription> The only required field is actually the URL </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> <URLInput url={url} setURL={setURL} setLabels={setLabels} /> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="name" className="text-right"> Name </Label> <Input id="name" placeholder="Something..." className="col-span-3" onChange={(e) => setName(e.target.value)} value={name} /> </div> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="labels" className="text-right"> Labels </Label> <Input id="labels" placeholder="work,product" className="col-span-3" onChange={(e) => setLabels(e.target.value)} value={labels} /> </div> </div> <DialogFooter> <Button onClick={() => edit({ id: bookmark.id, name, url, labels: labels.split(",").map((label) => label.trim()), })}> Edit bookmark </Button> </DialogFooter> </> )} </DialogContent> </Dialog> ); }
内容的提问来源于stack exchange,提问作者Filipe Picoito
相关产品推荐
相关产品推荐

