Next.jsでダークモード実装時に書いた気味の悪いコードをやや解消した
5分目次
ダークモードを使用する時に気味が悪いコードを書いたが何とか解消できた。個人的に気味悪くなくなっただけなので他人からしたら「まだまだ気味悪いだろ」と思われるかもしれないし「元々気味悪くないのでは」となる可能性もある。
要件と原因(なぜキモくなったか)
ダークモードを導入する際、トップレベルに位置するhtmlタグにdata属性を付与したり、あるいはクラスを付与して「ダークモードの活性を判別する」という形が考えられる。
要は
<html data-theme="dark">....</html>
:root{
--color-background: white;
}
:root[data-theme="dark"]{
--color-background: black;
}
.hoge{
background:var(--color-background);
}
のような具合だ。
で、このダークモードがONかOFFはグローバルステートで管理する必要がある。
ReduxでもRecoilでもいいが、今回僕はRecoilを採用していて、どちらを使うにしてもそうだがReactのトップレベルコンポーネントにProviderを配置してその配下のコンポーネントではRecoilのステートを参照できる。
Next.jsでは_document.tsx
、_app.tsx
の順に下位階層になっていってて、htmlにlangなど比較的静的な情報を設定するのが_document
、React上で状態などを引っ張ってきたりするのはわりと_app
側の責務で、普段であればそれにのっとり_app
に<RecoilRoot>
を設定するだけでいいのだが今回は<html>
より上に<RecoilRoot>
を配置しなければhtmlタグにRecoilのデータを渡せないという状態だった。
// _document.tsx
<Html lang="ja">
<body>
<Main />
<NextScript /> {/* 👈 こいつが_app.tsx */}
</body>
</Html>
// _app.tsx
<RecoilRoot> {/* ここに置くとLayoutコンポーネント以下からでしかRecoilを参照できない */}
<Layout>
<Component {...pageProps} />
</Layout>
</RecoilRoot>
そのため、_document.tsx
内でRecoilを利用しようとした。
// _document.tsx
import { darkModeState } from '@/atmos/state'
const Document = () => {
const isDarkMode = useRecoilValue(darkModeState)
return (
<RecoilRoot>
<Html data-theme={isDarkMode ? 'dark' : 'light'}>
<body>
<Main />
<NextScript />
</body>
</Html>
</RecoilRoot>
)
}
しかし、これだとerror - Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
という感じでエラーが出る。よくよく見れば<NextScript>
より上の階層でReactを実行しようとしてるので当然の帰結感がある。
そこで解決策として_app.tsx
でRecoilRoot
をセットし、その下に空のtsxを返すコンポーネントを置き、そのコンポーネントのロジックとしてuseEffect
でDOM操作をしてhtmlタグに属性付与という人の道から外れた方法を編み出した。これでダークモードの活性/非活性時にも一応htmlのdata-themeは動的に書き換わるようにはなった。
// _app.tsx
const SetDarkMode = () => {
const darkMode = useRecoilValue(darkModeState) ? 'dark' : 'light'
const setDarkModeToHTML = () => document.querySelector('html')?.setAttribute('data-theme', darkMode)
useEffect(() => setDarkModeToHTML(), [])
useEffect(() => setDarkModeToHTML(), [darkMode])
return <></>
}
function MyApp({ Component, pageProps }: AppProps) {
return (
<RecoilRoot>
<SetDarkMode />
<Layout>
<Component {...pageProps} />
</Layout>
</RecoilRoot>
)
}
export default MyApp
だけどなんかいくらなんでもキモすぎる。(個人の主観)
解決策
どうしたものかと悩んでいたがそもそもhtmlタグにダークモードのステートを反映させる必要ないのではという結論に至った。
htmlにダークモードの状態を付与させたかった理由はcss変数を定義するときに:root
を使用しているからだった。
:root{ /* 👈 htmlタグと同義 */
--color-background: black;
}
/* 👆 つまり上の文は『htmlタグより下の階層では変数(--color-background)をblackとして使える』という意味 */
ただ、別にbody
直下にdiv.wrap
を作ってその中でCSS変数使えばいいことに気づいた。
と言うわけで
import styles from '@/styles/pages/_app.module.scss'
/** ダークモードの値を動的に付与 */
const Wrapper: React.FunctionComponent<Props> = ({ children }) => {
const darkMode = useRecoilValue(darkModeState) ? styles.dark : styles.light
return <div className={`${styles.wrap} ${darkMode}`}>{children}</div>
}
function MyApp({ Component, pageProps }: AppProps) {
return (
<RecoilRoot>
<Wrapper>
<Layout>
<Component {...pageProps} />
</Layout>
</Wrapper>
</RecoilRoot>
)
}
.wrap {
&.light {
--color-text: white;
}
&.dark {
--color-text: black;
}
}
これで行けた。
次の課題
CSS変数でなくSCSSの変数でスマートにダークモード用の色を切り替えられないだろうか。SCSSの関数を使いたいので一部はSCSSの変数を使わざるを得なく、このダークモードにおいてはCSSの変数を使う感じ、究極に分かりづらいしだるい。