Next.js で Hydration Error が起きる理由と解決方法

2023/10/21に公開

🌼 はじめに

最近仕事で Next.js(app router)を触ってて、こういうエラーに出会いました。

Error: Text content does not match server-rendered HTML.
Warning: Text content did not match. Server: {sever text} Client: {client text}

今回の記事ではなぜこういうエラーが起きるのか、そしてどう解決すれば良いかを解説していきたいと思います。

Hydration とは

SSRを実行する場合、クライアント側でJavaScriptが全てのレンダリングを行うのではなく、サーバー側で事前に静的なHTMLを生成してクライアントに送信します。これにより初期表示を高速化できますが、HTMLだけを送信した段階ではまだインタラクションができません(クリックしても、、何も起きない、、!)

HTMLをレンダリングした後、クライアント側でJavaScriptをダウンロードし、すでに表示されているHTMLに結びつけます。これによりインタラクションが可能になります。

この静的な HTML に Javascript を結びつけてインタラクティブにするプロセスを Hydration といいます

Hydration は「水分補給」という意味の英単語なので、乾燥してる HTML に JavaScript を流してインタラクティブにすると思ったらイメージしやすいと思います。

Hydration Error の例

Hydration が何なのか分かったので、ぱぱっと Hydration Error を再現してみましょう。
localStorageに特定の値がある場合とない場合で文言を分ける簡単なコードを準備しました。

ClientComponent
"use client";

import { useEffect } from "react";

type ClientComponentProps = {
  name: string;
};

export const ClientComponent = ({ name }: ClientComponentProps) => {
  const hasVisited =
    typeof window !== "undefined" && !!localStorage.getItem("hasVisited");

  useEffect(() => {
    if (!hasVisited) localStorage.setItem("hasVisited", "true");
  }, []);

  const text = hasVisited ? "また会えて嬉しいです" : "はじめまして";

  return (
    <span>
      {text}{name}さん
    </span>
  );
};

ClientComponentのプリレンダリング(静的HTML生成)はサーバーサイドで行われます。つまり、window や localStorage などのウェブ API にはアクセスできません。

したがって、typeof window !== "undefined"true ならクライアント側であり、localStorageの値を取得します。false ならサーバー側であるため、false をそのまま使用します。このガードをしないと、サーバー側でlocalStorageにアクセスしようとするため、参照エラーが発生します

ということは、サーバー側では絶対に"はじめまして"という文言でHTMLを生成します。しかし、もしクライアント側でlocalStorageの値がtrueだった場合、クライアント側では"また会えて嬉しいです"という文言をレンダリングすることになります。

ここで、サーバー側とクライアント側でレンダリングされるテキストに差が生じるため、Hydration Error が発生します。「Text content does not match server-rendered HTML」というエラーメッセージはこの状況を示しています。

ブラウザで試してみると、静的なHTMLでは一瞬"はじめまして"が表示され、クライアント側の JavaScript が実行されると"また会えて嬉しいです"に変わることが確認できます。

🔔 実際のエラーは code sandbox でも確認できます。(2回開かないとエラー起きないですが笑)

解決方法

ではどうすれば Hydration Error を解決できるか見ていきましょう。

クライアント側の初期値をサーバー側に合わせる

方法1は、クライアント側で最初にレンダリングするコンテンツをサーバー側と一致させることです。適切な値に変更するのはその後に行います。これにより、Hydrationのタイミングでサーバー側とクライアント側のレンダリングコンテンツが一致し、エラーを防ぐことができます。

useStateuseEffectでこの動作を実装してみましょう。

export const ClientComponent = ({ name }: ClientComponentProps) => {
  const hasVisited =
    typeof window !== "undefined" && !!localStorage.getItem("hasVisited");

  // サーバー側で「はじめまして」で pre-render するため、初期値をサーバー側に合せる
  const [text, setText] = useState("はじめまして")
  useEffect(() => {
    if (!hasVisited) localStorage.setItem("hasVisited", "true");
    else setText("また会えて嬉しいです")
  }, []);

  return (
    <span>
      {text}{name}さん
    </span>
  );
};

一瞬"はじめまして"が見えて、クライアント側の JavaScript が実行されると"また会えて嬉しいです"に変わることは先と同じですが、 Hydration Error は消えることが確認できます。

個人的に大体の場合これで解決できるかなと思いました。

該当コンポーネントだけ SSR をさせない

方法2は、next/dynamicを使って該当コンポーネントを SSR しないようにすることです。SSR を行わない場合、クライアント側ですべてのレンダリングが行われるため、Hydration 自体が発生せず、Hydration Error も発生しなくなるでしょう。

next/dynamicは Lazy Loading の実装方法の一つです。この記事では詳しい説明は省略しますので、公式ウェブサイトをご参照ください。
https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading

では dynamic import のために文言が変わるところだけTextという別コンポーネントに切り出してみましょう。ロジックは最初のときと一緒です。

Text
import { useEffect } from "react";

export const Text = () => {
  const hasVisited =
    typeof window !== "undefined" && !!localStorage.getItem("hasVisited");

  useEffect(() => {
    if (!hasVisited) localStorage.setItem("hasVisited", "true");
  }, []);

  const text = hasVisited ? "また会えて嬉しいです" : "はじめまして";

  return <span>{text}</span>;
};

その後next/dynamicを使ってインポートします。SSRをオフにするのも忘れないように。

ClientComponent
"use client";

import dynamic from "next/dynamic";

const Text = dynamic(() => import("./Text").then((mod) => mod.Text), {
  ssr: false,
});

type ClientComponentProps = {
  name: string;
};

export const ClientComponent = ({ name }: ClientComponentProps) => {
  return (
    <span>
      <Text />
      <span>{name}さん</span>
    </span>
  );
};

これで Hydration Error が解決できました。

❗️❗️注意点❗️❗️

特定のコンポーネントだけ SSR をオフにしたらそこだけ pre-render されないので、以下のようにUIが何もない状態で急にぱっと出てきます。

ですので、この方法はファーストビューで見える位置のUIに使うことはおすすめできません。ファーストビューで見えないUIや、最初は非表示でクリック・ホーバーなどのイベントをトリガーに表示されるUIに使うことが良いと思います。

suppressHydrationWarning でワーニングを無視する

世の中にはどうしようもないときがいつもあります。

本当にどうにもならない場合はsuppressHydrationWarning={true}でワーニングを黙らせることができます。

ClientComponent
export const ClientComponent = ({ name }: ClientComponentProps) => {
  const hasVisited =
    typeof window !== "undefined" && !!localStorage.getItem("hasVisited");

  useEffect(() => {
    if (!hasVisited) localStorage.setItem("hasVisited", "true");
  }, []);

  const text = hasVisited ? "また会えて嬉しいです" : "はじめまして";

  return (
    // Hydration のワーニングを無視する
    <span suppressHydrationWarning>
      {text}{name}さん
    </span>
  );
};

suppressHydrationWarningは Next.js 固有の機能ではなく、divなど普通の React 要素に渡せる props です。
https://react.dev/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors

エラーを回避のための props なので、本当にどうしようもないときだけ使いましょう。

ちなみに1階層まで有効なので、Hydration Error が起きてる該当要素につける必要があります。試しにspan入れて2階層にしてみたらエラーが消えなくなりました。

ClientComponent
// ...

return (
  // 2階層上の要素に付けてるので有効になってない
  <span suppressHydrationWarning>
    <span>
      {text}{name}さん
    </span>
  </span>
);

🌷 終わり

これで Hydration Error について理解できました。今回の記事のほとんどは Next.js のドキュメントを参考にしてます。

https://nextjs.org/docs/messages/react-hydration-error

今回紹介した現象以外にも、タグのネスト関係が正しくない場合(pタグの中にpタグがあるとか)なども Hydration Error が起きる原因となるらしいので、ちゃんとエラーの原因を把握してから対応することが大事だと思います。

GitHubで編集を提案

Discussion