Next.js で Hydration Error が起きる理由と解決方法
🌼 はじめに
最近仕事で 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
に特定の値がある場合とない場合で文言を分ける簡単なコードを準備しました。
"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のタイミングでサーバー側とクライアント側のレンダリングコンテンツが一致し、エラーを防ぐことができます。
useState
とuseEffect
でこの動作を実装してみましょう。
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 の実装方法の一つです。この記事では詳しい説明は省略しますので、公式ウェブサイトをご参照ください。
では dynamic import のために文言が変わるところだけ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をオフにするのも忘れないように。
"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}
でワーニングを黙らせることができます。
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 です。
エラーを回避のための props なので、本当にどうしようもないときだけ使いましょう。
ちなみに1階層まで有効なので、Hydration Error が起きてる該当要素につける必要があります。試しにspan
入れて2階層にしてみたらエラーが消えなくなりました。
// ...
return (
// 2階層上の要素に付けてるので有効になってない
<span suppressHydrationWarning>
<span>
{text}、{name}さん
</span>
</span>
);
🌷 終わり
これで Hydration Error について理解できました。今回の記事のほとんどは Next.js のドキュメントを参考にしてます。
今回紹介した現象以外にも、タグのネスト関係が正しくない場合(pタグの中にpタグがあるとか)なども Hydration Error が起きる原因となるらしいので、ちゃんとエラーの原因を把握してから対応することが大事だと思います。
Discussion