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

Next.js 13自定义Dropdown组件Hydration失败问题求助

Next.js 13 App Router 自定义Dropdown组件 Hydration 失败问题

问题描述

使用Next.js 13 App Router开发项目时,引入自定义Dropdown组件出现「Hydration failed because the initial UI does not match what was rendered on the server」错误。已添加"use client"指令,同时尝试了以下方案均无效:

  • 通过dynamic设置ssr: false动态导入组件
  • 移除组件内部状态
  • 替换为Ant Design的Dropdown组件

相关代码

import { useState, useEffect, useRef, CSSProperties } from "react";

interface DropdownOption {
  label: string;
  value: string;
}
interface DropdownProps {
  value: string;
  options: DropdownOption[];
  onChange: any;
  style?: CSSProperties;
  loading?: boolean;
  placeholder?: string;
  ariaLabel?: string;
  disabled?: boolean;
  allowClear?: boolean;
  onClear?: () => void;
}

const Dropdown = ({
  options,
  value,
  style,
  onChange,
  loading,
  placeholder,
  ariaLabel,
  disabled,
  allowClear = false,
  onClear,
}: DropdownProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const [searchText, setSearchText] = useState("");
  const [showClearButton, setShowClearButton] = useState(allowClear);
  const [dropdownValue, setDropdownValue] = useState(value || "");
  const [dropdownLabel, setDropdownLabel] = useState(
    options.find((item) => item.value === value)?.label || ""
  );

  const dropdownRef = useRef(null);
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      // @ts-ignore
      if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
        setIsOpen(false);
      }
    };

    window.addEventListener("click", handleClickOutside);

    return () => {
      window.removeEventListener("click", handleClickOutside);
    };
  }, []);

  useEffect(() => {
    if (dropdownLabel.trim() !== "") {
      setShowClearButton(true);
    }
  }, [dropdownLabel]);
  const filteredOptions = options.filter((option) =>
    option.label.toLowerCase().includes(searchText.toLowerCase())
  );

  const handleButtonClick = () => {
    setSearchText("");
    setIsOpen(!isOpen);
  };

  const handleOptionClick = (option: DropdownOption) => {
    console.log(option);
    onChange(option.value);
    setDropdownValue(option.value);
    setDropdownLabel(option.label);

    setSearchText("");
    setIsOpen(false);
  };
  console.log(searchText);
  console.log("drop value", dropdownValue);
  console.log("drop label", dropdownLabel);

  const handleSearch = (e: any) => {
    setDropdownLabel(e.target.value);
    setSearchText(e.target.value);
  };

  const clearSelectHandler = () => {
    setDropdownValue("");
    setDropdownLabel("");
    setShowClearButton(false);
    onClear?.();
  };

  if (loading) {
    return <div>Loading dropdown...</div>;
  }

  if (disabled) {
    return (
      <div
        aria-label={ariaLabel && ariaLabel}
        style={style}
        className="relative"
      >
        <button
          className="bg-gray-200 border border-gray-300 rounded-md shadow-sm py-2 px-4 flex items-center justify-between w-full text-gray-500"
          disabled
        >
          <input
            type="text"
            className="  outline-0    block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm "
            placeholder={placeholder}
            value={dropdownLabel}
            onChange={handleSearch}
            disabled
          />
          <svg
            className="h-5 w-5 text-gray-500"
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 20 20"
            fill="currentColor"
            aria-hidden="true"
          >
            <path
              fillRule="evenodd"
              d="M10 14.293l-5.146-5.147a.5.5 0 01.708-.708L10 12.586l4.439-4.439a.5.5 0 11.708.708l-5.146 5.147a.5.5 0 01-.708 0z"
              clipRule="evenodd"
            />
          </svg>
        </button>
      </div>
    );
  }

  return (
    <div
      aria-label={ariaLabel && ariaLabel}
      style={style}
      ref={dropdownRef}
      className="relative"
    >
      <button
        className="bg-white border border-gray-300 rounded-md shadow-sm py-2 px-4 flex items-center justify-between w-full text-gray-700"
        onClick={handleButtonClick}
      >
        <input
          type="text"
          className="  outline-0      block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm "
          placeholder={placeholder}
          value={dropdownLabel}
          onChange={handleSearch}
        />
        {showClearButton ? (
          <svg
            onClick={clearSelectHandler}
            className="h-4 w-4 text-gray-700 z-10"
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 100 100"
            strokeWidth="2"
          >
            <path d="M 10,10 L 90,90 M 10,90 L 90,10" stroke="black" />
          </svg>
        ) : (
          <svg
            className="h-5 w-5 text-gray-500"
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 20 20"
            fill="currentColor"
            aria-hidden="true"
          >
            <path
              fillRule="evenodd"
              d="M10 14.293l-5.146-5.147a.5.5 0 01.708-.708L10 12.586l4.439-4.439a.5.5 0 11.708.708l-5.146 5.147a.5.5 0 01-.708 0z"
              clipRule="evenodd"
            />
          </svg>
        )}
      </button>
      {isOpen && (
        <div className="absolute z-10 bg-white border border-gray-300 rounded-md shadow-lg py-1  w-full">
          <ul>
            {filteredOptions.map((option) => (
              <li
                key={option.value}
                className="cursor-pointer px-4 py-2 hover:bg-gray-100"
                onClick={() => handleOptionClick(option)}
              >
                {option.label}
              </li>
            ))}
            {filteredOptions.length === 0 && (
              <div className="px-2 text-slate-500">No options left.</div>
            )}
          </ul>
        </div>
      )}
    </div>
  );
};

export default Dropdown;

页面使用代码

"use client";
import Input from "@/components/Input";
import React from "react";
import Button from "@/components/Button";
import Dropdown from "@/components/Dropdown";
const Recommend = () => {
  const eventHandler = (e: string) => {
    console.log(e);
  };
  return (
    <div className="h-screen flex flex-col bg-primary items-center">
      <h2 className=" w-full p-4 text-white text-center text-2xl">
        Configure your recommendations settings
      </h2>

      <div className=" mb-10 text-textPrimary text-center">
        <h3 className="text-bold pb-4 text-lg">Guide</h3>
        <div>
          Up to 5 seed values may be provided in any combination of artists,
          tracks and genres.
        </div>
      </div>

      <div className="max-w-2xl ">
        <Input
          placeholder="Track"
          labelPosition="center"
          label="Track"
          onChange={eventHandler}
        />
        <Input
          placeholder="Artist"
          labelPosition="center"
          label="Artist"
          onChange={eventHandler}
        />

        <p className="text-red-500 text-center p-4">Error</p>
        <Button
          className="bg-tertiary rounded-md py-2  w-full text-center"
          text="Search"
          onClick={() => console.log("respect")}
        />

        <Dropdown
          options={[
            { label: "Banana", value: "banana" },
            { label: "Cherry", value: "cherry" },
            { label: "Durian", value: "durian" },
            { label: "Elderberry", value: "elderberry" },
            { label: "Fig", value: "fig" },
          ]}
          onChange={eventHandler}
          value="banana"
          loading={false}
          placeholder="test"
          onClear={() => {
            console.log("respect");
          }}
        />
      </div>
    </div>
  );
};

export default Recommend;

尝试过的动态导入方案

const DynamicDropdown = dynamic(
  async () => import("../../components/Dropdown"),
  { ssr: false }
);

解决方案

1. 同步服务端与客户端的初始状态计算逻辑

组件中dropdownLabel的初始值依赖options.find(item => item.value === value)?.label,如果服务端与客户端的options内容/顺序不一致,会直接导致渲染差异。将初始状态计算移至useEffect中,确保仅在客户端执行:

// 替换原初始状态设置
const [dropdownLabel, setDropdownLabel] = useState("");

useEffect(() => {
  const matchedOption = options.find(item => item.value === value);
  setDropdownLabel(matchedOption?.label || "");
  setShowClearButton(allowClear || !!matchedOption?.label);
}, [value, options, allowClear]);

2. 隔离依赖浏览器环境的渲染逻辑

组件中isOpen初始值为false,服务端渲染时会生成对应的DOM,但客户端初始化时可能因浏览器事件逻辑导致状态变化。可以延迟初始化客户端专属状态:

const [isOpen, setIsOpen] = useState<boolean | undefined>(undefined);

useEffect(() => {
  // 仅在客户端设置初始状态
  setIsOpen(false);
  // 点击外部关闭逻辑
  const handleClickOutside = (event: MouseEvent) => {
    if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
      setIsOpen(false);
    }
  };
  window.addEventListener("click", handleClickOutside);
  return () => window.removeEventListener("click", handleClickOutside);
}, []);

// 渲染下拉列表时明确判断状态,避免服务端undefined导致的差异
{isOpen === true && (
  <div className="absolute z-10 bg-white border border-gray-300 rounded-md shadow-lg py-1  w-full">
    {/* 下拉列表内容 */}
  </div>
)}

3. 拆分搜索输入与选中标签的状态管理

当前搜索框直接绑定dropdownLabel,修改时会同步修改选中状态,容易引发服务端与客户端的渲染差异。拆分状态后分别管理:

const [searchValue, setSearchValue] = useState("");
const [selectedLabel, setSelectedLabel] = useState("");

// 初始化选中标签
useEffect(() => {
  const matchedOption = options.find(item => item.value === value);
  setSelectedLabel(matchedOption?.label || "");
}, [value, options]);

// 搜索框根据下拉状态切换绑定值
<input
  type="text"
  className="..."
  placeholder={placeholder}
  value={isOpen ? searchValue : selectedLabel}
  onChange={(e) => setSearchValue(e.target.value)}
/>

// 选中选项时同步更新状态
const handleOptionClick = (option: DropdownOption) => {
  onChange(option.value);
  setSelectedLabel(option.label);
  setSearchValue("");
  setIsOpen(false);
};

4. 延迟客户端专属UI的渲染

如果以上方法无效,可以通过useEffect标记客户端挂载完成,仅在挂载后渲染可能引发差异的内容:

const [isClientMounted, setIsClientMounted] = useState(false);

useEffect(() => {
  setIsClientMounted(true);
}, []);

// 服务端渲染占位UI,与客户端初始状态保持一致
if (!isClientMounted) {
  return (
    <div className="relative">
      <button className="bg-white border border-gray-300 rounded-md shadow-sm py-2 px-4 flex items-center justify-between w-full text-gray-700">
        <input
          type="text"
          className="outline-0 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
          placeholder={placeholder}
          value={options.find(item => item.value === value)?.label || ""}
          disabled
        />
        <svg className="h-5 w-5 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
          <path fillRule="evenodd" d="M10 14.293l-5.146-5.147a.5.5 0 01.708-.708L10 12.586l4.439-4.439a.5.5 0 11.708.708l-5.146 5.147a.5.5 0 01-.708 0z" clipRule="evenodd" />
        </svg>
      </button>
    </div>
  );
}

return (
  // 原组件完整内容
);

内容的提问来源于stack exchange,提问作者DuranGeorge55

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.23 02:47:05