Next.jsでブラウザAPI使おうとしたら「window is not defined」って怒られた

Next.jsで開発してると必ず出くわすエラーです。

ReferenceError: window is not defined

最初のうちはもちろん、もう知ってるはずなのに、ついwindow.localStorageと書いちゃってまたこのエラーが出ちゃうことも珍しくないです。

一応、モジュールのトップレベルで書いてしまっても、開発中やビルド時に怒ってくれるので、早い段階で気づけるのは救いですが…エラーを消せたと思ったら、次は画面のチラつき(フラッシュ)に悩まされたり、一筋縄ではいかなかったです。


何が起きてたのか

ユーザーがダークモード/ライトモード選んだやつをローカルストレージに保存しようとしてました。何も考えずにコンポーネントのトップで書いたんですけど、

// components/ThemeToggle.tsx
import { useState } from 'react';

const savedTheme = window.localStorage.getItem('theme');

export const ThemeToggle = () => {
  const [theme, setTheme] = useState(savedTheme || 'light');
  // ...
};

モジュールのトップレベルでwindowにアクセスしちゃってるのがダメなわけです。このファイルがインポートされた時点でサーバーサイドで実行されて、windowがないからコケる。要するに Node.js 上でブラウザにしかないオブジェクトを参照しようとしてるわけですね。


最初はuseEffectで逃げた

クライアントサイドだけで実行すればいいかってことでuseEffect使いました。マウント後にしか走らないから、エラーは消えます。

const [theme, setTheme] = useState('light');

useEffect(() => {
  const savedTheme = window.localStorage.getItem('theme');
  if (savedTheme) setTheme(savedTheme);
}, []);

でもページ読み込んだ直後、デフォルトのライトモードがチラッと見えてからダークモードに変わってしまうんですよね。サーバー側では常に初期値でレンダリングされるから、フラッシュが起こってしまう…


typeof windowで回避を試みる

検索するとよく出てきたやつです。

const isBrowser = typeof window !== 'undefined';
const savedTheme = isBrowser ? window.localStorage.getItem('theme') : null;

エラーは出ない。でもこれ、サーバー側では必ず'light'でレンダリングされて、クライアント側ではローカルストレージの値でレンダリングされるから、両者の結果が食い違ってしまう。うまく動いてるように見えても、開発者ツールで見るとHydrationエラーが出てたりするんです。しかもtypeof windowをそこら中に書くことになるのがきれいじゃないので、なんか違うなってなりました。


dynamic importでSSRごと切ってみる

いっそコンポーネント丸ごとSSRしなけりゃいいんじゃ?と思ってnext/dynamicssr: false試してみました。

import dynamic from 'next/dynamic';

const ThemeToggle = dynamic(
  () => import('../components/ThemeToggle'),
  { ssr: false }
);

エラーは消えるけど…。レンダリングがクライアント側だけ遅延するので、やっぱり画面のフラッシュが起きちゃいます。また、このコンポーネントのためだけにSSR切るのは大げさに思えたので見送りました。


自作フック化しても解決せず

useLocalStorageみたいなフック作ってみようかと思いました。

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(initialValue);

  useEffect(() => {
    try {
      const item = window.localStorage.getItem(key);
      if (item) setStoredValue(JSON.parse(item));
    } catch (error) {
      console.log(error);
    }
  }, [key]);

  const setValue = (value: T) => {
    setStoredValue(value);
    window.localStorage.setItem(key, JSON.stringify(value));
  };

  return [storedValue, setValue] as const;
}

見た目きれいにまとまったし、他でも使えるかも!と一瞬満足しかけたんですけど、結局useEffectの中身は変わってなかったです。さっきまで悩んでたフラッシュをラップし直しただけだった…

Reactのレンダリング(useEffect等)に任せる形をとる以上、こういうフラッシュは避けられないみたいです。


cookieとgetInitialPropsも検討した

フラッシュを本当に消すには、テーマ情報をサーバーサイドで事前に知っておく必要があるようです。ローカルストレージはサーバーから見えないから、cookieに保存する方式も考えてみました。

// pages/_app.tsx
MyApp.getInitialProps = async (appContext) => {
  const appProps = await App.getInitialProps(appContext);
  const cookies = appContext.ctx.req?.headers.cookie;
  let theme = 'light';
  if (cookies) {
    const match = cookies.match(/theme=([^;]+)/);
    if (match) theme = match[1];
  }
  return { ...appProps, theme };
};

でもテーマ切り替えるたびにdocument.cookie操作する必要があって、意外と面倒です。テーマ管理のためだけにgetInitialProps持ってくるのも大げさ、_app.tsxで使うと静的生成の恩恵が受けられなくなるのもあるので、結局見送りました。


結局こうなった

テーマに関しては、最初はシステム設定に従って、ただしユーザーが切り替えたらローカルストレージに保存、次回以降スクリプトタグで事前に読み込んでおくという組み合わせに。

// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html>
      <Head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                const savedTheme = localStorage.getItem('theme');
                const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
                const theme = savedTheme || (prefersDark ? 'dark' : 'light');
                document.documentElement.setAttribute('data-theme', theme);
              })();
            `,
          }}
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

保存されたテーマがあればそれを使い、初回はOSの設定を参照するようにしました。このスクリプトを<Head>の先頭で実行しておけば、ページのレンダリングが始まる前にdata-theme属性が付与されるから、フラッシュが起こりません。

でもこのスクリプト直書き、ちょっと不器用に感じる。実はnext-themesみたいなライブラリだったらこのあたりきれいに処理してくれるようです。このくらい自分で書けそうって思って再発明した結果、思わぬ遠回りになってしまいました。

ここまで書いておいてですが、プロジェクトはPages Routerで作ってます。でもApp Router(Server Components)使った場合であっても、サーバー側でcookie読めば結局動的レンダリングになります。Pages RouterのgetInitialPropsと一緒で、SSGの恩恵は失われちゃうわけです。

なので「SSGの速さは保ちつつ、フラッシュなしでテーマ出し分けたい」なら、この<Head>にスクリプト仕込むやり方が、一番現実的だと思います。


まとめというか反省

Next.jsのSSR/SSGとブラウザAPIの相性問題って、頭ではわかってるつもりでも開発してたら、つい境界線を見失ってハマりやすいです。

useEffectで囲んでエラーが消えたからいいや、で終わらせてもいいけど、ユーザー体験のことを考え始めると、途端にややこしくなる。ブラウザAPIはクライアントのもので、サーバーは知らなくて当然っていう、当たり前のことですけど、今回の試行錯誤で思い知らされました。