この記事の目次
タップすると同じページ内の見出しへ移動します
6 sections 01 よくあるパターンとその警告 ↓ 02 パターン1: マウント(hydration完了)判定 ↓ 03 パターン2: windowの値を読む(hostnameなど) ↓ 04 パターン3: localStorageを読む(Cookie同意バナー) ↓ 05 どうしても例外が要るとき ↓ 06 まとめ ↓ よくあるパターンとその警告
SSRするReactアプリで、こんなコードを書いたことはないでしょうか。
tsx ⧉
const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); // ⚠ Calling setState synchronously within an effect }, []); // mounted が true になってから広告やウィジェットを描画
hydration mismatchを避けるための定番テクニックですが、React 19世代のeslint(react-hooks v6)はこれにエラーを出します。マウント直後に必ず2回目の描画が走る(カスケードレンダリング)からです。
setTimeoutで包んで警告を默らせることはできますが、それは警告の意図を殺すだけ。正解は useSyncExternalStore です。
パターン1: マウント(hydration完了)判定
tsx ⧉
import { useSyncExternalStore } from "react"; const subscribeNoop = () => () => {}; export function useHydrated() { return useSyncExternalStore( subscribeNoop, // 購読不要(値は二度と変わらない) () => true, // クライアントsnapshot () => false, // サーバーsnapshot ); }
サーバーではfalse、hydration完了後のクライアントではtrue。useState + useEffectと同じ振る舞いを、setStateなしで実現できます。Reactが「hydration中はサーバーsnapshot、その後クライアントsnapshot」を保証してくれるのでmismatchも起きません。
パターン2: windowの値を読む(hostnameなど)
「サーバーではデフォルト値、クライアントでは window の実値」も同じ形で書けます:
tsx ⧉
const allowlistHost = useSyncExternalStore( subscribeNoop, () => window.location.hostname || "example.com", () => "example.com", );
useStateの初期値 + useEffectで上書き、という定番の2ステップが1式になります。
パターン3: localStorageを読む(Cookie同意バナー)
書き込みもある場合は、小さなストアを作ります。ポイントは「書いたら購読者に通知」と、プライベートモードでlocalStorageが例外を投げるケースのセッション内フラグです:
tsx ⧉
const listeners = new Set<() => void>(); let sessionHidden = false; // 保存できない環境でもこの表示中は隠す function subscribeConsent(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); } function readBannerVisible() { if (sessionHidden) return false; try { return localStorage.getItem("consent") !== "accepted"; } catch { return true; // 読めない環境では表示しておく } } function hideBanner(persist: () => void) { try { persist(); } catch {} sessionHidden = true; for (const l of listeners) l(); // 購読者に再描画を促す } export function CookieConsent() { const visible = useSyncExternalStore(subscribeConsent, readBannerVisible, () => false); if (!visible) return null; return <button onClick={() => hideBanner(() => localStorage.setItem("consent", "accepted"))}>OK</button>; }
useState版と違い、「状態の源泉はlocalStorageである」という事実がコードの構造にそのまま現れます。
どうしても例外が要るとき
認証直後のフルリロードなど、意図的にlintルールを破る場所は「理由をコメントに書いて1行だけdisable」が原則です:
tsx ⧉
// Full reload on purpose: the admin console is server-rendered from the // session cookie, so client navigation could show stale auth state. // eslint-disable-next-line @next/next/no-location-assign-relative-destination window.location.href = new URL("/admin", window.location.origin).href;
まとめ
• 「マウント後だけ表示」はuseState+useEffectではなくuseSyncExternalStoreで
• windowやlocalStorageは「外部ストアの購読」としてモデリングすると警告もmismatchも消える
• 書き込みがある場合はSetのlistenersと通知関数で小さなストアを作る
• lintを默らせるのではなく、設計を警告の意図に合わせる
Turnstile site key が未設定のため、このフォームは送信できません。管理者は NEXT_PUBLIC_TURNSTILE_SITE_KEY を設定してください。