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组件
相关代码
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
相关产品推荐
相关产品推荐

