skip to Main Content

So I’m using nextjs and I’m trying to build a Daisyui theme switcher. It generally works just fine, but there are some kinks I don’t quite understand.

I have a simple theme switch button:

// Themeswitch.jsx

'use client';

import { useContext } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';

import { ThemeContext } from 'context/SiteTheme';

const ThemeSwitch = function () {
    const { theme, setTheme, choices, darkMode } = useContext(ThemeContext);

    return (
        <button
            type="button"
            className="btn btn-ghost"
            onClick={() => {
                setTheme(theme === choices[0] ? choices[1] : choices[0]);
            }}
            aria-label="switch theme between light and dark"
        >
            <FontAwesomeIcon icon={darkMode ? faSun : faMoon} />
        </button>
    );
};

export default ThemeSwitch;

As you can see, it’s a client component. It still gets rendered on the server side, and If I log out the theme and dark mode, then on the server side, it logs out as "dim" and true and "emerald" and false on the client side, as it picks up the theme name from the local storage. But the thing is – the icon does not switch from "sun" to "moon" when rendering on the client side. Why?

And on a related note, this is the context provider looks like this:

'use client';

import { createContext, useMemo, useState, useEffect } from 'react';

export const ThemeContext = createContext();

const isLocalStorageEnabled = () => {
    try {
        const key = `__storage__test`;
        window.localStorage.setItem(key, null);
        window.localStorage.removeItem(key);
        return true;
    } catch (e) {
        return false;
    }
};

export const ThemeContextProvider = function ({ children, defaultTheme, choices }) {
    // const localTheme = isLocalStorageEnabled() && localStorage.theme;
    const localTheme = localStorage.theme;
    let baseTheme;
    if (choices.indexOf(localTheme) > -1) {
        baseTheme = localTheme;
    } else {
        baseTheme = defaultTheme;
    }

    const [theme, setTheme] = useState(baseTheme); // Replace null with your initial theme state

    useEffect(() => {
        document.documentElement.setAttribute('data-theme', theme);
        localStorage.theme = theme;
    }, [theme]);

    const memoizedContext = useMemo(
        () => ({ theme, setTheme, choices, darkMode: theme === defaultTheme }),
        [choices, defaultTheme, theme],
    );

    return <ThemeContext.Provider value={memoizedContext}>{children}</ThemeContext.Provider>;
};

If I run the code like this, then localStorage.theme generates an error on the server side, obviously, as it’s not available. However, having it generate an error on the server side is the only way to ensure that the theme does not visibly switch/flicker when rendered on the client side. If I comment in the line const localTheme = isLocalStorageEnabled() && localStorage.theme;, then there’s no error on the server side, but then the page first renders as darktheme, as that’s default and then switches to the light theme ( if it’s turned on) about 0.5 seconds later. Also, if I have an error on the server side, then the icon also renders correctly.

Is there a way to do that better?

2

Answers


  1. The issue you’re facing is due to the hydration process in Next.js. When the server renders the component, it uses the default theme because localStorage is not available on the server-side. However, when the client-side JavaScript kicks in, it reads the theme from localStorage and updates the state accordingly, causing a mismatch between the server-rendered output and the client-side state.

    To fix this,

    Let’s set a use mounted state to track the component’s mount status.

    const [mounted, setMounted] = useState(false);
    

    Then on the initial render, mounted mounted is set to false, so the provider does not render the ThemeContext.Provider . This prevent any client-side code from executing during server-side rendering.

    return mounted ? (
            <ThemeContext.Provider value={memoizedContext}>{children}</ThemeContext.Provider>
        ) : (
            <>{children}</>
        );
    

    Let’s use a useEffect hook to check the theme in localStorage and set the theme state accordingly. Let make sure, this hook only runs in client side by checking the availability of the localStorage. We keep the dependency array empty to make sure it runs only on initial render. Also after the theme is set, the mounted state should set to true

    useEffect(() => {
            const localTheme = localStorage.getItem('theme');
            if (localTheme && choices.includes(localTheme)) {
                setTheme(localTheme);
            } else {
                setTheme(defaultTheme);
            }
            setMounted(true);
        }, []);
    

    According to the logic we set on above using mounted state, The provider now renders the ThemeContext.Provider with the correct theme state, ensuring that the client-side and server-side outputs match.

    Then finally we use the existing useEffect hook to handle updating the data-theme attribute and localStorage whenever the theme state changes.

    useEffect(() => {
            document.documentElement.setAttribute('data-theme', theme);
            localStorage.setItem('theme', theme);
        }, [theme]);
    

    Here’s the completed code.

    //ThemeContextProvider 
    'use client';
    
    import { createContext, useMemo, useState, useEffect } from 'react';
    
    export const ThemeContext = createContext();
    
    export const ThemeContextProvider = function ({ children, defaultTheme, choices }) {
        const [theme, setTheme] = useState(defaultTheme);
        const [mounted, setMounted] = useState(false);
    
        useEffect(() => {
            const localTheme = localStorage.getItem('theme');
            if (localTheme && choices.includes(localTheme)) {
                setTheme(localTheme);
            } else {
                setTheme(defaultTheme);
            }
            setMounted(true);
        }, []);
    
        useEffect(() => {
            document.documentElement.setAttribute('data-theme', theme);
            localStorage.setItem('theme', theme);
        }, [theme]);
    
        const memoizedContext = useMemo(
            () => ({ theme, setTheme, choices, darkMode: theme === defaultTheme }),
            [choices, defaultTheme, theme],
        );
    
        return mounted ? (
            <ThemeContext.Provider value={memoizedContext}>{children}</ThemeContext.Provider>
        ) : (
            <>{children}</>
        );
    };
    
    Login or Signup to reply.
  2. i don’t know if it is a good idea , but to prevent the website from flickering, you may save the theme status in the server (db from example) where you can access the value on server side , maybe the session as well where you can access it on the server also

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search