Persist Theme Switcher State in Next JS Apps


Uncategorized

Updated Jul 14th, 2022

The best result I came across was here and he credits this Kent Dodds article here for using CSS variables with the custom data attribute over a theme provider from your CSS-in-JS library.

The Gist of It

Custom data attribute: In this situation a “data-theme” attribute is set on the body.

Notice: There is a duplicate selector on the body tag, likely due to light being the default

body, body[data-theme="light"] {...}

body[data-theme="dark"] {}

In other implementations I have seen the default set on the “:root” that acts as default light theme and a stand-alone “[data-theme=”dark”]” that is being applied to the document.

:root {...}
[data-theme="dark"] {...}

Remember: It’s Document > Root Element (HTML) the elements of head and body children of that.

Different Ways to Set the Data Attribute

This was the previous implementation I’ve seen with the “:root and data[]” approach:

  useEffect(() => {
    document.documentElement.setAttribute("data-theme", localStorage.getItem("theme") || "")
    setTheme(localStorage.getItem("theme") || "")
  }, [])

And here was the way described in the article:

useEffect(() => {
    document.body.dataset.theme = activeTheme;
  }, [activeTheme]);

Essentially the same thing.

Note: The dataset JavaScript is a document-oriented module (DOM) property to access the data attribute and set it on the JavaScript element. It is a DOM interface to set data elements on the application using JavaScript language.

The Magic to Reduce the Flashing

render() {
  const setInitialTheme = `
    function getUserPreference() {
      if(window.localStorage.getItem('theme')) {
        return window.localStorage.getItem('theme')
      }
      return window.matchMedia('(prefers-color-scheme: dark)').matches
                ? 'dark'
                : 'light'
    }
    document.body.dataset.theme = getUserPreference();
  `;
  return (
    <Html>
      <Head />
      <body>
        <script dangerouslySetInnerHTML={{ __html: setInitialTheme }} />
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

This allows use to refactor the initial value for “useState” and drop the “useEffect” with empty dependency array.

But We Still Get An Error

Server Error: ReferenceError: document is not defined.

So we have to use Next JS dynamic import. So the theme toggle button needs to be it’s own component.

The Not Correct Way

Persisting theme switcher state on page refresh in a Next JS app is a bit more complicated than just reaching for local storage. Local storage works with the help of the “useEffect” hook to ensure you are on the client.

  useEffect(() => {
    const value = localStorage.getItem("themePref")
    if (typeof value === "string") {
      appDispatch({ type: "setLightTheme" })
    }
    return () => {
      console.log("cleanup")
    }
  }, [])
  useEffect(() => {
    console.log("appState.theme changed")
    if (appState.theme !== "dark") {
      localStorage.setItem("themePref", JSON.stringify(appState.theme))
    }
    if (appState.theme === "dark" && typeof localStorage.getItem("themePref") === "string") {
      localStorage.removeItem("themePref")
    }
    return () => {
      console.log("teardown")
    }
  }, [appState.theme])

But you end up with a pretty nasty flashing result.

So the answer is to use cookies over local storage.

Note: You don’t want to store any user-sensitive data in a “non-httpOnly” cookie. But a theme switcher state preference is fine.

Heres’s the code, using the “js-cookie” package:

  useEffect(() => {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
    if (Cookies.get("themePref")) {
      appDispatch({ type: "setLightTheme" })
    }
  }, [])
  useEffect(() => {
    if (appState.theme === "light") {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
      Cookies.set("themePref", appState.theme, { expires: 7 })
    }
    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
    if (appState.theme === "dark" && Cookies.get("themePref")) {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
      Cookies.remove("themePref")
    }
  }, [appState.theme])

But the flashing persists here too.

So you need to read the cookie on the server and you can do this inside of a “getServerSideProps” function in Next JS. But this is only available in page components.

Sources

The best result I came across was here. Very impressive. Also interesting it didn’t even bother using a theme provider.

I liked this article here but obscufates the custom hook.

This article here about Gatsby pointed me to a Next JS implementation which comment’s mentioned to Inject a script in _document.js here