Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Themeの問題 #5

Closed
Shi-Shi-Ki opened this issue Feb 2, 2025 · 11 comments
Closed

Themeの問題 #5

Shi-Shi-Ki opened this issue Feb 2, 2025 · 11 comments

Comments

@Shi-Shi-Ki
Copy link
Owner

現状の実装だと、ライトモード中に画面遷移すると一瞬だけダークモードになってからライトモードに変わるという現象が起きている。

これはhtmlタグのdata-theme要素が初期表示時に設定されずに発生してしまっていることがわかっている。

themeの制御についてはdaisyUIを活用している
https://daisyui.com/components/theme-controller/

最終的な着地点として、画面遷移しても上記の現象が発生しなくなること。

@Shi-Shi-Ki
Copy link
Owner Author

next-themeというパッケージもあるが、suppressHydrationWarningを使うことが明記されている。
https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app

Note! If you do not add suppressHydrationWarning to your you will get warnings because next-themes updates that element. This property only applies one level deep, so it won't block hydration warnings on other elements.

1レベル下までの警告を無視させるとあるが、あまり良いアプローチではない気がする...
採用するかはちょっと考える(他の人はどうしているんだろ?

実装例
https://github.com/raaaahman/next-themes-daisyui-switcher

@Shi-Shi-Ki
Copy link
Owner Author

この動画の通りに修正したけど、画面遷移すると変わらずちらついてしまう
https://daisyui.com/resources/videos/next-js-14-theming-with-daisy-ui-light-and-dark-mode-zxrnzv0rews/

@Shi-Shi-Ki
Copy link
Owner Author

zustandを使ってみたけど結局はlocalstorageを使うので初期描画時にクライアントとサーバーとのギャップがあるという旨のエラーが出てしまう

Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used

@Shi-Shi-Ki
Copy link
Owner Author

Hydration failed...エラーになったコード
zustandを取り入れたのはThemeStore.tsxの部分

app/layout.tsx

import "./globals.css"
import "material-icons/iconfont/material-icons.css"
import ThemeChanger from "@/contexts/ThemeChanger"
import NextAuthProvider from "@/providers/NextAuth"
import { auth } from "@/auth"

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
}

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await auth()

  return (
    <html lang="ja">
      <body>
        <div className="drawer">
          <input id="my-drawer-3" type="checkbox" className="drawer-toggle" />
          <div className="drawer-content flex flex-col">
            {/* Navbar */}
            <NextAuthProvider>
              <ThemeChanger session={session}>
                {/* Page content here */}
                {children}
              </ThemeChanger>
            </NextAuthProvider>
          </div>
          <div className="drawer-side">
            <label
              htmlFor="my-drawer-3"
              aria-label="close sidebar"
              className="drawer-overlay"
            ></label>
            <ul className="menu bg-base-200 min-h-full w-80 p-4">
              {/* Sidebar content here */}
              <li>
                <a>Sidebar Item 1</a>
              </li>
              <li>
                <a>Sidebar Item 2</a>
              </li>
            </ul>
          </div>
        </div>
      </body>
    </html>
  )
}

ThemeProvider.tsx

"use client"
import { useEffect, useState, createContext } from "react"
import { themeType, ThemeType } from "@/utils/CommonTypes"

interface Theme {
  theme: themeType
  changer: (theme: themeType) => void
}

export const ThemeContext = createContext<Theme>({
  theme: ThemeType.DARK,
  changer: () => {},
})

export default function ThemeProvider({ children }: { children: React.ReactNode }) {
  const storedTheme = localStorage.getItem("theme")
  console.log("storedTheme", storedTheme)
  const initialTheme =
    storedTheme === "light"
      ? ThemeType.LIGHT
      : storedTheme === "dark"
        ? ThemeType.DARK
        : ThemeType.LIGHT
  const [theme, setTheme] = useState<themeType>(initialTheme)

  useEffect(() => {
    console.log("call useEffect.")
    document.documentElement.setAttribute("data-theme", theme)
    localStorage.setItem("theme", theme)
  }, [theme])

  const changer = (theme: themeType) => {
    setTheme(theme)
  }

  return <ThemeContext.Provider value={{ theme, changer }}>{children}</ThemeContext.Provider>
}

ThemeChanger.tsx

"use client"
import { themeType } from "@/utils/CommonTypes"
import { CommonHeader } from "@/app/CommonHeader"
import { Session } from "next-auth"
import { useThemeStore } from "./ThemeStore"

export default function ThemeChanger({
  children,
  session,
}: {
  children: React.ReactNode
  session: Session | null
}) {
  const { theme, setTheme } = useThemeStore()

  const handleToggle = (e: { target: { checked: boolean } }) => {
    console.log(`*** checked: ${e.target.checked}`)
    setTheme(theme === "light" ? "dark" : "light")
    document.documentElement.setAttribute("data-theme", theme)
  }

  return (
    <>
      <div data-theme={theme}>
        <div className="navbar bg-base-300 w-full mb-5">
          <div className="mx-2 flex-1 px-2">
            Navbar Title {session && `(${session.user?.email})`}
          </div>
          <div className="flex-none">
            {session && <CommonHeader />}
            <label className="swap swap-rotate btn btn-circle btn-sm m-2">
              <input type="checkbox" onChange={handleToggle} />
              <span className="swap-off material-icons">light_mode</span>
              <span className="swap-on material-icons">dark_mode</span>
            </label>
          </div>
        </div>
        {/* Page content here */}
        {children}
      </div>
    </>
  )
}

ThemeStore.tsx

import { themeType, ThemeType } from "@/utils/CommonTypes"
import { create } from "zustand"
import { persist, StorageValue } from "zustand/middleware"

interface ThemeState {
  theme: themeType
  setTheme: (theme: themeType) => void
}

export const useThemeStore = create<ThemeState>()(
  persist(
    (set) => ({
      theme:
        typeof window !== "undefined" && localStorage.getItem("theme-storage") === "light"
          ? ThemeType.LIGHT
          : ThemeType.DARK,
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: "theme-storage", // localStorageに保存する際のkey
      storage: {
        getItem: (key) => {
          //   console.log("getItem key:", key)
          return localStorage.getItem(key) as StorageValue<ThemeState> | null
        },
        setItem: (key, value) => {
          //   console.log(value.state.theme)
          return localStorage.setItem(key, value.state.theme.toString())
        },
        removeItem: (key: string) => localStorage.removeItem(key),
      },
    }
  )
)

@Shi-Shi-Ki
Copy link
Owner Author

Shi-Shi-Ki commented Feb 11, 2025

geminiに聞いてみた

~~

Next.jsでテーマ切り替えを実装する際、Hydration failedエラーに悩まされる方は多いかと思います。原因はサーバーサイドとクライアントサイドでlocalStorageの読み込みタイミングが異なるためです。

これまで様々な方法を試されたにも関わらず解決に至っていないとのこと、お気持ちお察しします。

suppressHydrationWarningは最終手段として有効な選択肢ですが、その前に他の実装者がどのように対応しているのか、より良い解決策がないか確認したいというお気持ち、よく分かります。

過去2年間に公開されたNext.jsのテーマ切り替え実装例を探したところ、以下のリポジトリが見つかりました。

例1: next-themes を利用した実装

例2: Recoil + localStorage + useEffect を利用した実装

  • リポジトリ: (具体的なリポジトリは見つかりませんでしたが、同様の実装は多数存在します)
  • 詳細: グローバルstate管理ライブラリRecoilとlocalStorage、useEffectを組み合わせた実装です。Recoilでテーマ状態を管理し、useEffectでlocalStorageとの同期を行います。この実装は、localStorageへのアクセスをuseEffect内で行うことで、Hydrationエラーを回避しています。

例3: context + localStorage + useEffect を利用した実装

  • リポジトリ: (具体的なリポジトリは見つかりませんでしたが、同様の実装は多数存在します)
  • 詳細: Reactのcontext APIとlocalStorage、useEffectを組み合わせた実装です。contextでテーマ状態を管理し、useEffectでlocalStorageとの同期を行います。Recoilを利用した実装と同様に、localStorageへのアクセスをuseEffect内で行うことで、Hydrationエラーを回避しています。

これらの実装例を参考に、ご自身のコードを見直してみて下さい。

suppressHydrationWarning について

suppressHydrationWarningは、Hydrationエラーを無視するためのReactの属性です。この属性を使用すると、Hydrationエラーが表示されなくなりますが、根本的な解決にはなりません。

Hydrationエラーは、サーバーサイドとクライアントサイドでレンダリング結果が異なる場合に発生します。suppressHydrationWarningを使用すると、この不一致が無視されるため、画面表示が崩れたり、JavaScriptが正常に動作しなくなる可能性があります。

そのため、suppressHydrationWarningはあくまで最終手段として捉え、可能な限り他の方法でHydrationエラーを解決することを推奨します。

補足

上記以外にも、様々な実装方法が存在します。それぞれの実装方法にはメリット・デメリットがありますので、ご自身の状況に合わせて最適な方法を選択して下さい。

もし、具体的なコードについて相談したい場合は、お気軽にご質問下さい。

@Shi-Shi-Ki
Copy link
Owner Author

Shi-Shi-Ki commented Feb 11, 2025

少なくとも例1と例3のアプローチはダメだった

(例1についてはsuppressHydrationWarningを使うことが前提となっており、これに違和感を思って不採用とした)

@Shi-Shi-Ki
Copy link
Owner Author

recoilを使おうとしたけどNextJSv15系(Reactv19系)はまだサポートしてなかった...
facebookexperimental/Recoil#2318

@Shi-Shi-Ki
Copy link
Owner Author

Shi-Shi-Ki commented Feb 15, 2025

画面遷移(または画面リフレッシュ)によるチラつきは公式サイトでも発生している(記載日時現在)
https://mui.com/material-ui/customization/dark-mode/

qiitaによる解説を見つけた
https://qiita.com/KadoProG/items/15ceebf1aef774690bdf

Hydration failedエラーはクライアントとサーバーとのギャップで発生しているエラーなので、qiitaの解説を見るとCookieを使ってそれを回避している
(localstorageがダメならサーバーのKVSか...と思っていたけどCookieでも回避できるのか)

@Shi-Shi-Ki
Copy link
Owner Author

やっとできた。。。
前スレのqiitaを参考にしてCookieを使う形にした(これが正攻法なやり方かは不明だけど)

layout.tsx(つまりサーバーサイド)でnext/handersを使ってCookieを取得
-> 初期表示はこれを使う

js-cookieを使ってクライアントサイドでCookieを取得/変更
-> テーマ切り替えを行ったらCookieの内容を書き換えつつ、タグのdata-theme要素を書き換えて切り替える

ただ、まだボタンの初期表示の問題があるので引き続き修正する

@Shi-Shi-Ki
Copy link
Owner Author

テーマ切り替えボタンの初期設定ができた

inputのcheckedをtrue/false設定すれば良い

...
<input type="checkbox" checked={theme === ThemeType.DARK} onChange={handleToggle} />
...

@Shi-Shi-Ki
Copy link
Owner Author

コードは後で整理するとして、以下のPRで修正対応を行なった
#7

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant