next-themesについてのメモ(`theme`と`resolvedTheme`の使い分け/Hydration Error対策)
ドキュメント読んで微妙にわからなかった部分の備忘録。
TL;DR
-
themeは「何のテーマを選んでいるか」を返す(lightordarkorsystem)
systemに明示的に切り替えたい場合などにはこっち -
resolveThemeは「実際に何のテーマが使われているか」を返す(lightordark)
light/darkを切り替えたいだけならこっち -
theme,resolveThemeを使用する処理はマウントされてからじゃないとダメ
next-themes基礎知識
Next.js用のテーマ管理ライブラリです。App Routerならlayoutで{children}を<ThemeProvider>でラップするだけで簡単にダークモードを実装できます。
// app/layout.jsx
import { ThemeProvider } from 'next-themes'
export default function Layout({ children }) {
return (
<html suppressHydrationWarning>
<head />
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
tailwindを使う場合は以下の設定を追加してあげて、
const config: Config = {
// ...
darkMode: ["selector", ".dark"],
classNameに追加してあげるだけでOKです。簡単!
<p className="text-black dark:text-white">text</p>
ドキュメントはこちら
テーマの切り替え方法
テーマの切り替えにはuseThemeのsetThemeを使います。
これに渡したテーマがアプリ全体で共有されることになります。
import { useTheme } from 'next-themes'
const ThemeChanger = () => {
const { setTheme } = useTheme()
// Hydration Error対策(後述)
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<div>
<button onClick={() => setTheme('light')}>Light Mode</button>
<button onClick={() => setTheme('dark')}>Dark Mode</button>
<button onClick={() => setTheme('system')}>Dark Mode</button>
</div>
)
}
基本はlight,dark,systemの3種類のどれかを使うと思いますが、自作のテーマを指定することもできます。systemはOSで指定されているやつですね。Chrome Dev ToolsのEmulate CSS media feature prefers-color-schemeで指定できるあれです。
テーマの使用
現在のテーマはthemeかresolveThemeを用いて受け取ることができます。
が、この二つはちょっとした違いがあるので気を付ける必要があります。
まずはドキュメントを見てみましょう。
theme: Active theme name
resolvedTheme: If enableSystem is true and the active theme is "system", this returns whether the system preference resolved to "dark" or "light". Otherwise, identical to theme
ちょっとわかりづらいですが、要はsystemが指定された時の振る舞いが違うだけです。
setTheme("system"); // themeにsystemを指定.OSはダークモード設定
console.log("theme is", theme); // theme is system
console.log("resolvedTheme is", resolvedTheme); // resolvedTheme is dark
themeはそのままsystemとなりますが、resolvedSystemならsystemが結果的に何になってるのかまで解決してくれます。
themeとresolvedThemeの使い分け
resolveThemeの使用例
ただテーマを使用する+切り替えるだけならresolvedThemeで十分です。
const DarkModeToggle = () => {
const { resolvedTheme, setTheme } = useTheme();
const handleToggle = () => {
setTheme(resolvedTheme === "light" ? "dark" : "light");
};
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<button onClick={handleToggle} >
{resolvedTheme === "light" ? (
<MoonIcon />
) : (
<SunIcon />
)}
</button>
);
};
export default DarkModeToggle;
themeの使用例
一方、ユーザーに明示的にsystemを選ばせるようなUIにしたい場合はthemeが有効です。
export default function ThemeSelector() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<div>
<label>
テーマを選択:
<select
value={theme}
onChange={(e) => setTheme(e.target.value)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</label>
</div>
);
}
Material-UIのドキュメントページはこの作りになっていますね。 (非Webエンジニア向けのサイトではやめた方がいい気はします。systemって言われてもピンと来ないですよね。。)
Hydration Error対策
theme,resolveThemeともにSSRの段階ではundefinedになります。
そのため、対策なしでこれらを使用するとSSR/CSRで描画結果に差異が生じてHydration mismatchが起きてしまいます。
ここまでの説明の中にもあった以下のコードはその対策です。
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
これにより、クライアントでマウントされるまでは描画が行われないようになるため、SSR/CSRの差がなくなり、エラーを避けることができます。
なお、実際のコードでnullを返すとlayout shiftが問題になると思われます。
スケルトンか何かを返すようにしましょう。
参考
Discussion