Render-After-Mount Without setState: Fixing React's setState-in-effect Warning with useSyncExternalStore
# React 19's eslint flags the classic mounted-flag pattern. Instead of silencing it with setTimeout, model hydration state, window values, and localStorage as external stores — three real examples.
Turnstile site key が未設定のため、このフォームは送信できません。管理者は NEXT_PUBLIC_TURNSTILE_SITE_KEY を設定してください。
[ ]まだコメントはありません。
The familiar pattern and its new warning
If you write SSR React, you have probably written this:
tsx
const [mounted, setMounted] = useState(false);useEffect(() => { setMounted(true); // ⚠ Calling setState synchronously within an effect}, []);// render ads / widgets only after mounted === true
It is the standard hydration-mismatch dodge — and the React 19-era eslint (react-hooks v6) now flags it, because it forces a guaranteed second render right after mount (a cascading render).
You can silence the warning by wrapping the call in setTimeout, but that defeats the warning's purpose. The real fix is useSyncExternalStore.
Pattern 1: a hydration flag
tsx
import { useSyncExternalStore } from "react";const subscribeNoop = () => () => {};export function useHydrated() { return useSyncExternalStore( subscribeNoop, // nothing to subscribe to — the value never changes again () => true, // client snapshot () => false, // server snapshot );}
false on the server, true after hydration — the exact behavior of the useState+useEffect version, with no setState at all. React guarantees the server snapshot during hydration and the client snapshot afterwards, so there is no mismatch.
Pattern 2: reading window values (hostname etc.)
"Default on the server, real value on the client" fits the same shape:
The usual two-step (useState initial value, then overwrite in an effect) collapses into one expression.
Pattern 3: localStorage (a cookie-consent banner)
When you also write, build a tiny store. Two details matter: notify subscribers after writing, and keep a session flag for private-mode browsers where localStorage throws:
tsx
const listeners = new Set<() => void>();let sessionHidden = false; // stay hidden for this page even if persisting failedfunction 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; // can't read → show the banner }}function hideBanner(persist: () => void) { try { persist(); } catch {} sessionHidden = true; for (const l of listeners) l(); // trigger re-render in subscribers}export function CookieConsent() { const visible = useSyncExternalStore(subscribeConsent, readBannerVisible, () => false); if (!visible) return null; return <button onClick={() => hideBanner(() => localStorage.setItem("consent", "accepted"))}>OK</button>;}
Unlike the useState version, the structure now states the truth: localStorage is the source of this state.
When you genuinely need an exception
Some places intentionally break a lint rule — a full page reload right after authentication, say. The rule there: one-line disable, with the reason written right above it:
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-destinationwindow.location.href = new URL("/admin", window.location.origin).href;
Takeaways
• Render-after-mount belongs to useSyncExternalStore, not useState+useEffect
• Model window and localStorage as external stores — the warning and the mismatch both disappear
• For writable state, a Set of listeners and a notify function make a complete store
• Don't silence lint; reshape the design to match the warning's intent
Turnstile site key が未設定のため、このフォームは送信できません。管理者は NEXT_PUBLIC_TURNSTILE_SITE_KEY を設定してください。