javascriptreactjsnext.jslocal-storage

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


I am using next 13.1.0. I have a ContextProvider that sets a light and dark theme

'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 };

I use it in my root layout

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;

And I consume the theme value in my 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 };

I get the error

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

enter image description here

React docs error link

I don't understand why I am getting this error because I am accessing localStorage inside my useEffect, so I expect the HTML generated on the server to be the same with the client before the first render.

How can I solve this error?


Solution

  • I have made a workaround that solves the issue for now at the cost of giving up SSR.

    By using a dynamic import on my ContextProvider, I disable server-rendering and the error is gone. As a bonus, the flashing issue from my default dark theme to my light theme saved on localStorage is gone. But I give up the benefits of SSR. If someone finds a better solution, please do share.

    import '@styles/globals.scss';
    import { GlobalContent } from '@components/GlobalContent/GlobalContent';
    import { Inter } from '@next/font/google';
    import dynamic from 'next/dynamic';
    import { ReactNode } from 'react';
    
    const inter = Inter({ subsets: ['latin'] });
    
    interface RootLayoutProps {
      children: ReactNode
    }
    
    // Fixes: Hydration failed because the initial UI does not match what was rendered on the server.
    const DynamicContextProvider = dynamic(() => import('@components/ContextProvider/ContextProvider').then(mod => mod.ContextProvider), {
      ssr: false
    });
    
    const RootLayout = ({ children }: RootLayoutProps) => {
      return (
        <html lang="en" className={inter.className}>
          <head />
          <body>
            <DynamicContextProvider>
              <GlobalContent>
                {children}
              </GlobalContent>
            </DynamicContextProvider>
          </body>
        </html>
      );
    };
    
    export default RootLayout;
    

    This solution does not disable SSR site wide. I added a new test page with the following code

    async function getData() {
      const res = await fetch('https://rickandmortyapi.com/api/character', { cache: 'no-store' });
      if (!res.ok) {
        throw new Error('Failed to fetch data');
      }
    
      return res.json();
    }
    
    export default async function Page() {
      const data = await getData();
    
      return (
        <main>
          {data.results.map((c: any) => {
            return (
              <p key={c.id}>{c.name}</p>
            );
          })}
        </main>
      );
    }
    

    After running npm run build, I can see that the test page is using ssr

    enter image description here

    On checking the response for the test page, I can see the HTML response

    enter image description here