hydration-safe-client-state

The youteacher landing page is server-rendered (Next.js). Two pieces of UI depend on things the server cannot know: the visitor's saved theme, and whether the launch date has passed. If the client rendered a different first frame than the server sent, React would report a hydration mismatch. The landing page avoids this with one consistent pattern — useSyncExternalStore with a server snapshot that is deliberately the safe, neutral value, so server and first client render agree.

The theme store

The theme lives in module-level state, not React state — a currentTheme variable and an array of listeners defined once at the top of Header.tsx. A small hand-written store wraps it: subscribe pushes/removes a listener, getSnapshot returns currentTheme, and a setter writes the new value, persists it to localStorage under the key theme, mirrors it onto document.documentElement's data-theme dataset attribute, and notifies listeners.

When the module first loads in a browser, an initializer reads the stored theme out of localStorage (if present) into currentTheme and stamps the data-theme attribute. On the server that initializer is skipped — it guards on window being defined.

useTheme reads this store through useSyncExternalStore. Its server snapshot returns 'light' unconditionally. So the server always renders as if the theme were light; the real stored theme is applied on the client. Because CSS keys off the data-theme attribute (set imperatively, outside React's render), the actual colors can differ from the light default without React ever seeing a mismatched tree.

The mounted guard

A second useSyncExternalStore is used purely as a client-detection flag: its client snapshot returns true, its server snapshot returns false, and its subscribe is a no-op. This yields false on the server and during the first client render, then true afterward. The theme-toggle button is rendered only when this flag is true, so the interactive control never appears in the server HTML and cannot cause a mismatch.

The launch gate

FoundingMember.tsx gates its call-to-action on a fixed launch timestamp (April 26 2026, Asia timezone offset). It uses the same trick: useSyncExternalStore with a no-op subscribe, a client snapshot that compares Date.now() against the launch timestamp, and a server snapshot that always returns false (not launched).

The consequence: the server, and every client render before launch, shows the CTA as a disabled button carrying a pre-launch hint as its tooltip. Once the launch timestamp has passed, the client snapshot flips to true and the same CTA renders as an ordinary link to the product signup page. No timer, no re-fetch — the gate is a pure comparison evaluated on the client, with the server pinned to the pre-launch state so hydration always matches.

Why this shape

The unifying rule: anything the server can't know renders as a fixed, safe default on the server, and the truth arrives only on the client. Theme defaults to light; the toggle is hidden until mounted; the launch CTA defaults to disabled. Each uses useSyncExternalStore's third argument (the server snapshot) as the place to pin that default, which is exactly what that argument exists for.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →