You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

NextJS13+Shadcn组件条件渲染出现Hydration错误求助

NextJS13 + Shadcn 书签编辑组件 Hydration 问题排查

我在使用NextJS13搭配Shadcn开发书签编辑组件时,条件渲染环节出现了Hydration问题(错误截图: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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.09 10:55:58