cat ./posts/usesyncexternalstore-hydration-safe-ui.en.md
Hydration-Safe UI with useSyncExternalStore
# A source-verified guide to hydration snapshots, stable subscriptions, and this blog’s cross-tab cookie-consent store.
cat ./posts/usesyncexternalstore-hydration-safe-ui.en.md
# A source-verified guide to hydration snapshots, stable subscriptions, and this blog’s cross-tab cookie-consent store.
No comments yet.
SSR React applications often use a mount flag like this to avoid a hydration mismatch:
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);As of July 24, 2026, this repository has eslint-plugin-react-hooks 7.1.1 installed. It reports that code as a react-hooks/set-state-in-effect error because synchronous setState starts another render immediately after the effect.
That does not mean every value belongs in useSyncExternalStore. Derive values from props and state during render when possible, and keep React-only state in useState or useReducer. useSyncExternalStore fits when a snapshot comes from a store or browser API outside React.

*Diagram: two snapshot paths converge on one initial-UI contract. It is not a timing diagram and does not claim that the post-hydration render disappears.*
This blog implements src/lib/use-hydrated.ts as follows:
import { useSyncExternalStore } from "react";
export const subscribeNoop = () => () => {};
export function useHydrated() {
return useSyncExternalStore(subscribeNoop, () => true, () => false);
}The third argument, getServerSnapshot, returns false during server rendering and hydration, so the initial client UI matches the HTML. After hydration, React reads the client snapshot and sees true. That snapshot transition still causes a render. What this removes is the extra update caused by synchronous setState inside an effect.
This distinction matters. useHydrated is not a performance trick that eliminates the second render. It is a small hook that states the server-snapshot and client-snapshot boundary explicitly.
The official documentation requires the value returned by getSnapshot to be immutable. While the store has not changed, it must return the same cached snapshot. Passing a different subscribe function on each render also makes React resubscribe, so put a stable function outside the component.
The snapshots above are booleans, so they are immutable. subscribeNoop is outside the component and is only appropriate because the value does not change again after hydration.
This blog uses the same no-op subscription for the hostname shown in allowlist guidance:
const allowlistHost = useSyncExternalStore(
subscribeNoop,
() => window.location.hostname || "kirinnoblog.work",
() => "kirinnoblog.work",
);This works because the hostname is a value that does not change during the lifetime of the same document. Do not reuse subscribeNoop for browser APIs that can change while the page is open, such as navigator.onLine, matchMedia, or storage modified by another tab. Those require a real subscribe function that installs and removes the corresponding event listener.
Cookie consent includes writes and changes from other tabs. The implementation lives in src/lib/consent-store.ts, while the UI only needs these operations:
const visible = useSyncExternalStore(
subscribeConsent,
readConsentBannerVisible,
() => false,
);
setConsentChoice("accepted");
setConsentChoice("denied");readConsentBannerVisible builds its snapshot from the persisted choice, dismissed state, and an in-session fallback. setConsentChoice first stores the explicit accepted or denied value in sessionChoice, attempts to persist it to localStorage, and then emits a change event for the same document. If persistence throws in private mode or another restricted environment, the explicit choice remains authoritative for that document.
subscribeConsent listens for that custom event and for the browser storage event, so it also observes changes made by another tab. A separate fail-closed path stops optional script loading and reloads when another tab changes accepted to denied. A tiny Set plus localStorage.setItem would not cover those cross-tab and denial boundaries.
The server snapshot is false, so neither the consent banner nor optional scripts are considered granted in the server HTML or during hydration. The client snapshot then decides whether to show the banner or enable consented features.
useState or useReducer.subscribeNoop and an explicit server snapshot.subscribe with real setup and cleanup.Instead of hiding the warning with setTimeout or a broad lint disable, classify the source of the value and its update path. The resulting code can explain both its hydration boundary and its subscription behavior.