はじめに
こんにちは!Next.jsでWeb開発をしている皆さん、SSR(サーバーサイドレンダリング)の挙動で「あれ?」と思った経験はありませんか?
先日、外部の分析ツールを導入するためにscriptタグをページに埋め込む作業をしていました。そのスクリプトには、クライアントサイドでしか取得できない動的な値(例えば、Cookieから生成したユニークIDなど)を含める必要がありました。
「scriptタグなんだから、ブラウザ(クライアント)で実行されるに決まってる」。
そう高を括っていたのですが、これが思わぬ落とし穴でした。ツールに送信されるはずのデータがundefinedになってしまう現象に遭遇したのです。
さらに厄介なことに、ブラウザの開発者ツールでElementsパネルを見ると、scriptタグの中身は正しく値が入っているように見える…。
今回は、この不可解な現象の原因と、Next.jsの機能を活用したスマートな解決策について共有したいと思います。
問題になったコード
何が起きていたのか? - 問題の再現
まず、問題が起きていたコードのイメージはこのような形です。
getDynamicValueは、クライアントサイドで実行された時だけ特定の値を返し、サーバーサイドではundefinedを返す関数です。
// ブラウザ環境でのみ意味のある値を返す関数
export const getDynamicValue = () => {
// windowオブジェクトはブラウザにしか存在しない
if (typeof window !== 'undefined') {
// 例として、localStorageから値を取得する
return localStorage.getItem('session_id') || 'some-default-value';
}
// SSR時にはundefinedを返す
return undefined;
};
そして、この関数をページコンポーネントで呼び出し、scriptタグに埋め込んでいました。
import { getDynamicValue } from '../lib/utils';
const AnalyticsComponent = () => {
const dynamicValue = getDynamicValue();
return (
<>
<h1>My Page</h1>
{/* その他のコンテンツ */}
<script
dangerouslySetInnerHTML={{
__html: `
// 外部の分析ツールにデータを送信
externalAnalytics.send({
sessionId: "${dynamicValue}", // ← ここが問題!
});
`,
}}
/>
</>
);
};
このコードの問題点は、getDynamicValue()が呼び出されるタイミングにあります。
Next.jsでは、ページへの初回アクセス時にサーバーでReactコンポーネントをレンダリングし、HTMLを生成します。この時、AnalyticsComponentもサーバーで実行されます。
サーバーサイドレンダリング(SSR)時
- getDynamicValue()がサーバーで呼び出される
- typeof windowは'undefined'なので、関数はundefinedを返す
- dynamicValue変数はundefinedになる
結果として、生成されるHTMLは以下のようになる。
<script>
externalAnalytics.send({
sessionId: "undefined",
});
</script>
このHTMLがブラウザに送信され、最初のデータ送信はsessionId: "undefined"で実行されてしまう。
クライアントサイド(ハイドレーション)時
- ブラウザはサーバーから受け取ったHTMLとJavaScriptを読み込む
- Reactが起動し、サーバーで生成されたHTML構造とクライアントでレンダリングした結果を比較・一致させるハイドレーションという処理を行う
- この時、クライアントサイドでAnalyticsComponentが再度レンダリングされる
- getDynamicValue()が今度はブラウザで実行され、localStorageから正しい値(例: 'abc-123')を取得する。
- ReactはDOMを更新し、scriptタグの中身を正しい値で再構成する
このため、初期ロード時のデータ送信は失敗しているにもかかわらず、その後の開発者ツールでの確認時にはDOMが更新されて正常に見える、という混乱を招く状況が生まれていました。
scriptタグとNext.jsのレンダリングサイクルの誤解
今回のケースでは、スクリプト文字列を生成するgetDynamicValue()がSSRの過程で解決されてしまったのが原因でした。
解決策
クライアントサイドでの実行を保証する
この問題を解決するには、「このスクリプトは、クライアントサイドでハイドレーションが完了した後に実行する」ということをNext.jsに明示的に伝える必要があります。
これには、Next.jsが提供する next/script コンポーネント を使うのが最もクリーンで推奨される方法です。
next/scriptにはstrategyというプロパティがあり、スクリプトを読み込むタイミングを制御できます。今回は"afterInteractive"を使用します。
strategy="afterInteractive": ページが操作可能になった後(ハイドレーション完了後)にスクリプトを読み込み、実行する。
ただし、単純にscriptタグをScriptコンポーネントに置き換えるだけでは、dangerouslySetInnerHTMLの中身がSSR時に評価される問題は解決しません。
そこで、コンポーネントがクライアントサイドでマウントされたことを示す状態をuseEffectとuseStateで作り、その状態に基づいてScriptコンポーネントをレンダリングするようにします。
import Script from 'next/script'; // next/scriptをインポート
import { useState, useEffect } from 'react';
import { getDynamicValue } from '../lib/utils';
const AnalyticsComponent = () => {
const [isClient, setIsClient] = useState(false);
// このuseEffectはクライアントサイドでのみ実行される
useEffect(() => {
setIsClient(true);
}, []);
return (
<>
<h1>My Page</h1>
{/* その他のコンテンツ */}
{/* isClientがtrueの時(=クライアントでのマウント後)のみScriptコンポーネントをレンダリング */}
{isClient && (
<Script
id="analytics-script-sender" // Hydrationエラーを避けるためユニークなIDを付与
strategy="afterInteractive"
>
{`
// このコードブロックはクライアントでのみ実行される
const dynamicValue = window.localStorage.getItem('session_id') || 'some-default-value';
externalAnalytics.send({
sessionId: dynamicValue, // これで正しく値が送られる!
});
`}
</Script>
)}
</>
);
};
補足:インラインスクリプトをdangerouslySetInnerHTMLではなく、子要素として直接文字列で渡すのがnext/scriptの推奨する書き方です。
この修正により、以下の流れが実現します。
SSR時
isClientはfalseなので、Scriptコンポーネントはレンダリングされず、HTMLに含まれない。
クライアントサイドでのマウント時
- useEffectが実行され、isClientがtrueになる
- コンポーネントが再レンダリングされ、ScriptコンポーネントがDOMに追加される
- strategy="afterInteractive"の指定により、ハイドレーション完了後にスクリプトが実行される
- このタイミングでは確実にブラウザ環境なので、localStorageから正しい値を取得でき、意図したデータ送信が成功する
まとめ
今回はシチュエーション自体が例外的なものだったため参考にできるコードもなく苦労しましたが、おかげでレンダリングの仕組みをちゃんと理解するいい機会になりました。
どこで何がレンダリングされるのか、まだまだ何となくで使っている部分は多いですが少しずつ理解を深めていければいいかなと思っています。