Next.jsのHydration Errorに、useEffect以外で回避する

Next.jsで開発しているとよく遭遇するあのエラー…。

Error: Hydration failed because the initial UI does not match what was rendered on the server.

Hydration Errorですね。サーバー側のレンダリング結果とクライアント側の結果がズレたときに出るエラーです。

とりあえずuseEffectという力技は卒業して、もうちょっとマシなやり方を試行錯誤した話です。


何が起きていたのか

私の場合は「現在時刻のリアルタイム表示」とか「ダークモードの切り替え」とかのコンポーネントを作ったときでした。

サーバーでビルドされた時点の時間とか、ローカルストレージに保存された設定なんかが、ブラウザ側では違う値になってて、「サーバーとクライアントで違うぞ!」とReactに怒られたわけです。

よくある「とりあえずuseEffect」

以下みたいに書くと、手っ取り早く消せます。

import { useState, useEffect } from 'react';

export default function CurrentTime() {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  if (!isMounted) return null;

  return <div>現在の時刻: {new Date().toLocaleTimeString()}</div>;
}

確かにエラーは消えてくれるんですが、気になる点がありました。

  • 無駄な再レンダリング(空で描画 → マウント後に本描画で2回走る)
  • 一瞬なんかチラつく
  • 毎回この定型文を書くのが面倒くさい

もっと良い方法がないか調べて、実際に導入したのが以下の3つです。


方法1:next/dynamic

一番手軽だったのが、最初からSSRしないって決めちゃうやり方です。
TimeDisplay.tsx みたいに別ファイルに切り出して、呼び出し側でこうします。

import dynamic from 'next/dynamic';

const TimeDisplayNoSSR = dynamic(
  () => import('@/components/TimeDisplay'),
  { ssr: false }
);

export default function HomePage() {
  return (
    <main>
      <h1>マイページ</h1>
      <TimeDisplayNoSSR />
    </main>
  );
}

コンポーネント側に変なロジック要らないし、呼び出し側で {ssr: false}つけるだけ。SEO関係ない部分(ログイン後のユーザー名とか、リアルタイム情報)は、最初からSSRの対象から外しちゃうのが楽でいいと思いました。


方法2:React 18のuseSyncExternalStore

もう一つ試したのが、ブラウザのAPI(ウィンドウサイズとか)を扱うときの公式推奨フックです。

useSyncExternalStoreを使うと、コンポーネントと外部の値(ストア)を安全に同期できます。
ウィンドウサイズで表示を変える処理で実装してみました。

import { useSyncExternalStore } from 'react';

const subscribe = (callback: () => void) => {
  window.addEventListener('resize', callback);
  return () => window.removeEventListener('resize', callback);
};

const getClientSnapshot = () => window.innerWidth;
const getServerSnapshot = () => 1024;

export default function ResponsiveComponent() {
  const width = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);

  return <div>現在の画面幅: {width}px</div>;
}

コードの量は少し増えるけど、useEffect + useStateでチマチマやるよりリサイズしたときの表示のカクつきが防げるし、イベントのクリーンアップもきれいに書けます。

ただこれ単体ではHydration Errorは消えないので、方法1のようにnext/dynamicもセットにします。

自作のカスタムフックとしてuseWindowSizeみたいなの作るなら、今後これで行くべきだなって思いました。


方法3:suppressHydrationWarning

どうしてもコンポーネント側で修正できない場合は、該当するタグにsuppressHydrationWarningを付けることで、Hydration Errorを無視することもできます。

return <div suppressHydrationWarning>{content}</div>;

あくまで警告を無視してるだけなので、致命的なバグまでスルーされちゃう可能性があるのでピンポイントで使うのが安全です。


使い分けの指針

いろいろ試した結果、自分の中での基準はこんな感じです。

場面選択肢
日付やユーザー情報の一部だけSSRを除外したいnext/dynamic{ssr: false}
ウィンドウサイズやブラウザAPIと連動させたいnext/dynamic+useSyncExternalStore(こだわり派)
いち早く黙らせたい(文字のブレは許容)suppressHydrationWarning(最終手段)

仕組みを理解して適切な方法を選ぶと、コードがシンプルになって描画のチラつきも減らせます。同じエラーで困ったときの参考になれば。