cat ./posts/usesyncexternalstore-hydration-safe-ui.ja.md
useSyncExternalStoreで作るhydration安全なUI
# 現行ESLintとproduction codeを照合し、hydration snapshot、stable subscription、Cookie同意storeの境界を正確に解説します。
cat ./posts/usesyncexternalstore-hydration-safe-ui.ja.md
# 現行ESLintとproduction codeを照合し、hydration snapshot、stable subscription、Cookie同意storeの境界を正確に解説します。
まだコメントはありません。
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のerrorとして検出します。同期的なsetStateがeffect直後にもう一度renderを始めるためです。
ただし、どんなstateもuseSyncExternalStoreへ置き換えればよいわけではありません。render中にpropsやstateから導出できる値はその場で計算し、React内だけで完結するstateはuseStateやuseReducerを使います。useSyncExternalStoreが合うのは、Reactの外にあるstoreやbrowser APIのsnapshotを購読するときです。

*図: serverとbrowserの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はserver renderとhydration中にfalseを返し、最初のHTMLとclientの初期UIを一致させます。hydration後は第2引数のclient snapshotがtrueになるため、その切り替えに伴う再描画自体は起きます。なくなるのは、effect内の同期setStateによる追加更新です。
この違いは重要です。useHydratedは「2回目のrenderを消すperformance trick」ではなく、server snapshotとclient snapshotの境界をReactへ明示するための小さなhookです。
公式docsでは、getSnapshotが返す値はimmutableでなければならず、storeが変わっていない間はcachedされた同じsnapshotを返す必要があります。また、subscribeをrenderごとに別functionとして渡すと再購読されるため、安定したfunctionをcomponentの外に置きます。
上の例ではsnapshotがbooleanなのでimmutableです。subscribeNoopもcomponentの外にあり、hydration後に値が変化しないことを前提にしています。
このブログでは、allowlist案内用のhostnameも同じno-op subscriptionで読みます。
const allowlistHost = useSyncExternalStore(
subscribeNoop,
() => window.location.hostname || "kirinnoblog.work",
() => "kirinnoblog.work",
);これはhostnameが同じdocumentの存続中に変化しない値だから成立します。navigator.onLine、matchMedia、別tabから変わるstorageなど、表示中に変化し得るbrowser APIへsubscribeNoopを流用してはいけません。その場合は、実際のevent listenerを登録・解除するsubscribeが必要です。
Cookie同意は読み取りだけでなく書き込みと別tabからの変更があります。実装はsrc/lib/consent-store.tsに集約し、UI側は次の3つだけを使います。
const visible = useSyncExternalStore(
subscribeConsent,
readConsentBannerVisible,
() => false,
);
setConsentChoice("accepted");
setConsentChoice("denied");readConsentBannerVisibleは保存済みchoice、dismissed state、session内fallbackからsnapshotを作ります。setConsentChoiceは明示されたacceptedまたはdeniedをまずsessionChoiceへ保持し、localStorageへの保存を試みた後、同じdocumentへchange eventを送ります。private modeなどで保存に失敗しても、明示したchoiceはそのdocument中で失われません。
subscribeConsentはcustom change eventに加えてstorage eventも購読します。これにより別tabの変更を反映します。別tabでacceptedからdeniedへ変わった場合は、optional scriptの読込を止めてreloadするfail-closed処理も別途あります。単なるSetとlocalStorage.setItemだけでは、このcross-tabと拒否側の境界を満たせません。
server snapshotをfalseにしているため、server HTMLとhydration中には同意bannerもoptional scriptも許可済みとは扱いません。client snapshotを読んでから、banner表示または同意済み機能へ移ります。
useStateまたはuseReducersubscribeNoopとserver snapshotを使うsubscribeを作る警告をsetTimeoutや広いlint disableで隠すのではなく、値のsourceと更新経路を先に分類すると、hydrationと購読の境界を説明できる実装になります。