cat ./posts/usesyncexternalstore-hydration-safe-ui.ja.md
useSyncExternalStoreでhydrationに強いUIを作る
# 現行ESLintと本番コードを照合し、hydration用スナップショット、安定した購読、Cookie同意ストアの境界を正確に説明します。
cat ./posts/usesyncexternalstore-hydration-safe-ui.ja.md
# 現行ESLintと本番コードを照合し、hydration用スナップショット、安定した購読、Cookie同意ストアの境界を正確に説明します。
まだコメントはありません。
SSRするReactアプリでは、hydration mismatchを避けるために次のようなmount flagを見かけます。
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);2026年7月24日時点、このリポジトリに入っているeslint-plugin-react-hooks 7.1.1は、このコードをreact-hooks/set-state-in-effectのエラーとして検出します。同期的なsetStateがeffect直後にもう一度renderを始めるためです。
ただし、どんな状態もuseSyncExternalStoreへ置き換えればよいわけではありません。render中にpropsや状態から導出できる値はその場で計算し、React内だけで完結する状態はuseStateやuseReducerを使います。useSyncExternalStoreが合うのは、Reactの外にあるstoreやブラウザAPIのsnapshotを購読するときです。

*図: サーバーとブラウザの2経路が同じ初期UI契約へ合流する概念図。処理回数や「2回目のrenderが消える」ことを示すtimelineではありません。*
このブログのsrc/lib/use-hydrated.tsは次の形です。
import { useSyncExternalStore } from "react";
export const subscribeNoop = () => () => {};
export function useHydrated() {
return useSyncExternalStore(subscribeNoop, () => true, () => false);
}第3引数のgetServerSnapshotはサーバー renderとhydration中にfalseを返し、最初のHTMLとクライアントの初期UIを一致させます。hydration後は第2引数のクライアント snapshotがtrueになるため、その切り替えに伴う再描画自体は起きます。なくなるのは、effect内の同期setStateによる追加更新です。
この違いは重要です。useHydratedは「2回目のrenderを消すperformance trick」ではなく、サーバー snapshotとクライアント snapshotの境界をReactへ明示するための小さなフックです。
公式ドキュメントでは、getSnapshotが返す値はimmutableでなければならず、storeが変わっていない間はcachedされた同じsnapshotを返す必要があります。また、subscribeをrenderごとに別functionとして渡すと再購読されるため、安定したfunctionをコンポーネントの外に置きます。
上の例ではsnapshotがbooleanなのでimmutableです。subscribeNoopもコンポーネントの外にあり、hydration後に値が変化しないことを前提にしています。
このブログでは、allowlist案内用のhostnameも同じno-op subscriptionで読みます。
const allowlistHost = useSyncExternalStore(
subscribeNoop,
() => window.location.hostname || "kirinnoblog.work",
() => "kirinnoblog.work",
);これはhostnameが同じ文書の存続中に変化しない値だから成立します。navigator.onLine、matchMedia、別タブから変わるストレージなど、表示中に変化し得るブラウザ APIへsubscribeNoopを流用してはいけません。その場合は、実際のイベント listenerを登録・解除するsubscribeが必要です。
Cookie同意は読み取りだけでなく書き込みと別タブからの変更があります。実装はsrc/lib/consent-store.tsに集約し、UI側は次の3つだけを使います。
const visible = useSyncExternalStore(
subscribeConsent,
readConsentBannerVisible,
() => false,
);
setConsentChoice("accepted");
setConsentChoice("denied");readConsentBannerVisibleは保存済みchoice、dismissed 状態、セッション内フォールバックからsnapshotを作ります。setConsentChoiceは明示されたacceptedまたはdeniedをまずsessionChoiceへ保持し、localStorageへの保存を試みた後、同じ文書へchange イベントを送ります。非公開 モードなどで保存に失敗しても、明示したchoiceはその文書中で失われません。
subscribeConsentはcustom change イベントに加えてstorage eventも購読します。これにより別タブの変更を反映します。別タブでacceptedからdeniedへ変わった場合は、optional スクリプトの読込を止めてreloadするfail-closed処理も別途あります。単なるSetとlocalStorage.setItemだけでは、このcross-タブと拒否側の境界を満たせません。
サーバー snapshotをfalseにしているため、サーバー HTMLとhydration中には同意bannerもoptional スクリプトも許可済みとは扱いません。クライアント snapshotを読んでから、banner表示または同意済み機能へ移ります。
useStateまたはuseReducersubscribeNoopとサーバー snapshotを使うsubscribeを作る警告をsetTimeoutや広いlint disableで隠すのではなく、値のソースと更新経路を先に分類すると、hydrationと購読の境界を説明できる実装になります。