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のデータを渡せないという状態だった。

NG1
// _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を利用しようとした。

NG2
// _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.tsxRecoilRootをセットし、その下に空のtsxを返すコンポーネントを置き、そのコンポーネントのロジックとしてuseEffectでDOM操作をしてhtmlタグに属性付与という人の道から外れた方法を編み出した。これでダークモードの活性/非活性時にも一応htmlのdata-themeは動的に書き換わるようにはなった。

OK
// _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変数使えばいいことに気づいた。

と言うわけで

_app.tsx
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>
  )
}
_app.module.scss
.wrap {
  &.light {
    --color-text: white;
  }
  &.dark {
    --color-text: black;
  }
}

これで行けた。

次の課題

CSS変数でなくSCSSの変数でスマートにダークモード用の色を切り替えられないだろうか。SCSSの関数を使いたいので一部はSCSSの変数を使わざるを得なく、このダークモードにおいてはCSSの変数を使う感じ、究極に分かりづらいしだるい。

  • SNSでシェアしよう
  • Twitterでシェア
  • FaceBookでシェア
  • Lineでシェア
  • 記事タイトルとURLをコピー
トップへ戻るボタン

\ HOME /

トップへ戻る