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

Next.js 13服务端与客户端UI不匹配致Hydration失败问题求解

Next.js 13.1.0 主题切换 Hydration 失败问题解决

我在Next.js 13.1.0中实现了浅色/深色主题切换的ContextProvider,相关代码如下:

ContextProvider 代码

'use client';
import { Theme, ThemeContext } from '@store/theme';
import { ReactNode, useState, useEffect } from 'react';

interface ContextProviderProps {
  children: ReactNode
}

const ContextProvider = ({ children }: ContextProviderProps) => {
  const [theme, setTheme] = useState<Theme>('dark');

  useEffect(() => {
    const storedTheme = localStorage.getItem('theme');
    if (storedTheme === 'light' || storedTheme === 'dark') {
      setTheme(storedTheme);
    } else {
      localStorage.setItem('theme', theme);
    }
    // added to body because of overscroll-behavior
    document.body.classList.add(theme);
    return () => {
      document.body.classList.remove(theme);
    };
  }, [theme]);

  const toggle = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
};

export { ContextProvider };

根布局代码

import '@styles/globals.scss';
import { GlobalContent } from '@components/GlobalContent/GlobalContent';
import { ContextProvider } from '@components/ContextProvider/ContextProvider';
import { Inter } from '@next/font/google';
import { ReactNode } from 'react';

const inter = Inter({ subsets: ['latin'] });

interface RootLayoutProps {
  children: ReactNode
}

const RootLayout = ({ children }: RootLayoutProps) => {
  return (
    <html lang="en" className={inter.className}>
      <head />
      <body>
        <ContextProvider>
          <GlobalContent>
            {children}
          </GlobalContent>
        </ContextProvider>
      </body>
    </html>
  );
};

export default RootLayout;

GlobalContent 组件代码

'use client';
import styles from '@components/GlobalContent/GlobalContent.module.scss';
import { GlobalHeader } from '@components/GlobalHeader/GlobalHeader';
import { GlobalFooter } from '@components/GlobalFooter/GlobalFooter';
import { ThemeContext } from '@store/theme';
import { ReactNode, useContext } from 'react';

interface GlobalContentProps {
  children: ReactNode
}

const GlobalContent = ({ children }: GlobalContentProps) => {
  const { theme } = useContext(ThemeContext);
  return (
    <div className={`${theme === 'light' ? styles.lightTheme : styles.darkTheme}`}>
      <GlobalHeader />
      <div className={styles.globalWrapper}>
        <main className={styles.childrenWrapper}>
          {children}
        </main>
        <GlobalFooter />
      </div>
    </div>
  );
};

export { GlobalContent };

运行后出现错误:

Hydration failed because the initial UI does not match what was rendered on the server.

我已经在useEffect中访问localStorage,以为服务端生成的HTML和客户端首次渲染内容一致,请问该如何解决?


问题原因

虽然你在useEffect中获取localStorage,但useEffect是在客户端DOM挂载后才执行的。服务端渲染时,组件初始theme为'dark',生成的HTML对应darkTheme类;而客户端首次hydration时,初始状态也是'dark',但useEffect执行后如果localStorage中存储的是'light',会立刻更新theme,导致客户端DOM突然切换为lightTheme,和服务端生成的HTML不匹配,触发hydration错误。

同时,服务端没有document.body,所以服务端渲染的HTML中body不会添加主题类,而客户端useEffect执行后会给body添加类,这也会导致DOM不匹配。


解决方法

1. 初始化时直接获取客户端localStorage值

修改ContextProvider的状态初始化逻辑,在客户端直接读取localStorage,避免在hydration后才更新主题:

'use client';
import { Theme, ThemeContext } from '@store/theme';
import { ReactNode, useState, useEffect } from 'react';

interface ContextProviderProps {
  children: ReactNode
}

const ContextProvider = ({ children }: ContextProviderProps) => {
  // 客户端初始化时直接读取localStorage,服务端返回默认值'dark'
  const [theme, setTheme] = useState<Theme>(() => {
    if (typeof window !== 'undefined') {
      const storedTheme = localStorage.getItem('theme');
      return storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'dark';
    }
    return 'dark';
  });

  useEffect(() => {
    // 处理body类名,以及初始化localStorage(如果没有存储的话)
    document.body.classList.add(theme);
    if (!localStorage.getItem('theme')) {
      localStorage.setItem('theme', theme);
    }
    
    return () => {
      document.body.classList.remove(theme);
    };
  }, [theme]);

  const toggle = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
};

export { ContextProvider };

2. 添加hydration完成标记,避免初始不匹配

此时服务端渲染的还是默认'dark'主题,而客户端可能是'light',依然会有hydration不匹配的问题。需要在消费主题的组件中,等到hydration完成后再应用真实主题:

修改GlobalContent组件:

'use client';
import styles from '@components/GlobalContent/GlobalContent.module.scss';
import { GlobalHeader } from '@components/GlobalHeader/GlobalHeader';
import { GlobalFooter } from '@components/GlobalFooter/GlobalFooter';
import { ThemeContext } from '@store/theme';
import { ReactNode, useContext, useState, useEffect } from 'react';

interface GlobalContentProps {
  children: ReactNode
}

const GlobalContent = ({ children }: GlobalContentProps) => {
  const { theme } = useContext(ThemeContext);
  const [isHydrated, setIsHydrated] = useState(false);

  // hydration完成后标记状态
  useEffect(() => {
    setIsHydrated(true);
  }, []);

  // 未完成hydration时使用服务端默认的darkTheme,完成后切换到真实主题
  const currentThemeClass = isHydrated 
    ? (theme === 'light' ? styles.lightTheme : styles.darkTheme) 
    : styles.darkTheme;

  return (
    <div className={currentThemeClass}>
      <GlobalHeader />
      <div className={styles.globalWrapper}>
        <main className={styles.childrenWrapper}>
          {children}
        </main>
        <GlobalFooter />
      </div>
    </div>
  );
};

export { GlobalContent };

3. 同步html标签的主题类(可选)

如果你的全局样式依赖html标签的主题类,可以在ContextProvider的useEffect中添加同步逻辑:

useEffect(() => {
  document.body.classList.add(theme);
  document.documentElement.classList.add(theme); // 添加html标签类
  if (!localStorage.getItem('theme')) {
    localStorage.setItem('theme', theme);
  }
  
  return () => {
    document.body.classList.remove(theme);
    document.documentElement.classList.remove(theme); // 移除html标签类
  };
}, [theme]);

原理说明

  • 初始化时直接读取客户端localStorage,确保客户端首次渲染的主题和用户存储的一致。
  • 添加isHydrated状态,让客户端在hydration阶段先渲染和服务端一致的默认主题,完成后再切换到真实主题,避免DOM不匹配。
  • 同步body和html的类名,确保全局样式正确应用。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.04 19:30:35