You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The React adapter's useSelector passes the live client snapshot function as the getServerSnapshot argument of useSyncExternalStoreWithSelector. Any store write that lands between the server render and hydration — a localStorage sync effect, a module-init read, anything that runs before a code-split boundary hydrates — makes hydration render a value that no longer matches the server HTML, and React reports a hydration mismatch. There is currently no way for a consumer to provide the value the server actually rendered.
I'd like to propose an opt-in getServerSnapshot member on the existing UseSelectorOptions, defaulting to today's behavior — and I'm happy to submit the PR.
constgetSnapshot=useCallback(()=>source.get(),[source])returnuseSyncExternalStoreWithSelector(subscribe,getSnapshot,getSnapshot,// ← getServerSnapshot: returns the *current* value, not the value the server renderedselector,compare,)
React's contract for the third argument (react.dev): "It will be used only during server rendering and during hydration of server-rendered content on the client. The server snapshot must be the same between the client and the server." During hydration React renders whatever getServerSnapshot() returns and reconciles it against the server HTML. Passing the live getSnapshot is equivalent to asserting "the store has not changed since the server render" — which the library can't guarantee, and the consumer can't fix, because no API accepts a server snapshot. React's docs explicitly place this on the store library: "Your external store should provide instructions on how to do that."
Since useStore (deprecated alias), useAtom, and _useStore all delegate to useSelector, this is a single choke point — one fix covers every React read path.
Reproduction
Each step is ordinary app behavior:
The server renders theme = "light" (the store's initial value, chosen for SSR consistency)
The client hydrates the shell; an effect syncs the store from localStorage → store.setState(() => "dark")
A lazy chunk inside <Suspense> finishes loading and hydrates late
During that boundary's hydration useSelector returns "dark" while the server HTML says "light":
Hydration failed because the server rendered text didn't match the client. … External changing data without sending a snapshot of it along with the HTML. … + dark / - light
I verified this with renderToString + hydrateRoot (capturing onRecoverableError) under vitest/jsdom, on React 18.3.1 and 19.2.8, against @tanstack/react-store@0.11.0:
scenario
result
store untouched (baseline)
✅ clean
store mutated after shell hydration, boundary hydrates late
❌ mismatch
store mutated before hydrateRoot (module-init localStorage read) — no code splitting involved
❌ mismatch
selected slice changed
❌ mismatch
only a non-selected field changed
✅ clean (the failure boundary is exactly the selected value)
same scenario via createAtom + useAtom
❌ mismatch
control: same scenario, raw useSyncExternalStore with an honest server snapshot
✅ clean — and the UI still settles on the current value after hydration
The control row is the point: React's built-in mechanism handles this case exactly as designed — the library just doesn't expose it.
A runnable version of all of the above (minimal repro + the full scenario matrix, with React 18.3.1 and 19.2.8 variants) is here: https://github.com/bokeeeey/tanstack-store-ssr-repro — pnpm install && pnpm test.
Minimal repro test (drop into a vitest + jsdom project)
import{Store,useSelector}from"@tanstack/react-store";import*asReactfrom"react";import{Suspense,act,lazy,useSyncExternalStore}from"react";import{typeRoot,hydrateRoot}from"react-dom/client";import{renderToString}from"react-dom/server";import{afterEach,expect,test}from"vitest";(globalThisastypeofglobalThis&{IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT=true;functionApp({ Label }: {Label: React.ComponentType}){return(<div><p>shell</p><Suspensefallback={<p>loading…</p>}><Label/></Suspense></div>);}functiondeferredLazy(Component: React.ComponentType){letresolveInner: (()=>void)|undefined;constLazy=lazy(()=>newPromise<{default: React.ComponentType}>((r)=>{resolveInner=()=>r({default: Component});}),);return{
Lazy,resolve: ()=>{if(!resolveInner)thrownewError("lazy factory was never invoked");resolveInner();},};}constcleanups: Array<()=>void>=[];afterEach(()=>{for(constcleanupofcleanups.splice(0))cleanup();});asyncfunctionrunScenario(Label: React.ComponentType,mutateStore: ()=>void){constcontainer=document.createElement("div");document.body.appendChild(container);container.innerHTML=renderToString(<AppLabel={Label}/>);expect(container.textContent).toContain("light");const{ Lazy, resolve }=deferredLazy(Label);constrecoverableErrors: Array<unknown>=[];letroot!: Root;awaitact(async()=>{root=hydrateRoot(container,<AppLabel={Lazy}/>,{onRecoverableError: (error)=>recoverableErrors.push(error),});});cleanups.push(()=>{act(()=>root.unmount());container.remove();});expect(recoverableErrors).toHaveLength(0);// shell hydrated cleanawaitact(async()=>mutateStore());// e.g. a localStorage sync effectawaitact(async()=>resolve());// the lazy chunk arrives → boundary hydratesreturn{ container, recoverableErrors };}test("BUG: useSelector — late-hydrating boundary mismatches server HTML",async()=>{consttheme=newStore<"light"|"dark">("light");functionLabel(){return<p>{useSelector(theme)}</p>;}const{ recoverableErrors }=awaitrunScenario(Label,()=>theme.setState(()=>"dark"),);expect(recoverableErrors.length).toBeGreaterThan(0);// hydration mismatch});test("CONTROL: honest getServerSnapshot — same scenario, no mismatch",async()=>{consttheme=newStore<"light"|"dark">("light");functionLabel(){constvalue=useSyncExternalStore((onStoreChange)=>theme.subscribe(onStoreChange).unsubscribe,()=>theme.get(),()=>"light"asconst,// the value the server actually rendered);return<p>{value}</p>;}const{ container, recoverableErrors }=awaitrunScenario(Label,()=>theme.setState(()=>"dark"),);expect(recoverableErrors).toHaveLength(0);expect(container.textContent).toContain("dark");// still converges after hydration});
We hit this in production on a TanStack Start app: a theme store synced from localStorage after mount, and a late-hydrating chunk's theme toggle read the updated value during hydration. Our workaround is a mount gate — render the known server value until mounted — which costs an extra post-hydration re-render for something React supports natively.
TanStack Router already needs this
Router's own SSR code routes around the missing server story in three ways today:
ClientOnly.tsx's useHydrated() is React.useSyncExternalStore(subscribe, () => true, () => false) — a hand-rolled honest server snapshot, reaching past @tanstack/react-store to get one
useRouterState.tsx, Scripts.tsx, headContentUtils.tsx, and Matches.tsx avoid calling the hook on the server entirely (isServer branches guarded by eslint-disable react-hooks/rules-of-hooks)
routerStores.ts swaps in non-reactive store factories on the server (createNonReactiveMutableStore)
Meanwhile @tanstack/react-form uses useSelector internally with no server branching, and ships SSR examples (tanstack-start, remix, next-server-actions) — those are exposed to the same mismatch whenever form state changes before a boundary hydrates.
Proposal
Extend the existing options bag — the same place compare landed via #243/#244:
exportinterfaceUseSelectorOptions<TSelected,TSource>{compare?: (a: TSelected,b: TSelected)=>boolean/** * Snapshot used during server rendering and during hydration. * Should return the value the server render used. * Defaults to reading the live value — today's behavior. * Keep the reference stable across renders (as with `selector`). */getServerSnapshot?: ()=>TSource}
returnuseSyncExternalStoreWithSelector(subscribe,getSnapshot,options?.getServerSnapshot??getSnapshot,// never pass undefined — React throws during hydration without itselector,compare,)
Strictly additive. Without the option, behavior is byte-for-byte today's. The default matters: nanostores/react first changed their default to "always the initial value" and had to revert it (nanostores/react#39 → #40, which landed an opt-in ssr option instead). Opt-in seems clearly right.
Precedent. react-redux added serverState on <Provider> for exactly this failure mode (reduxjs/react-redux#1835: "By the time a particular component is hydrated, the store might've already been mutated"), with the same getServerState || store.getState fallback shape. zustand's hook passes an honest third argument (getInitialState, pmndrs/zustand#2277). nanostores/react exposes ssr: () => serverState.
Typing note. The server snapshot returns the pre-selector TSource, so UseSelectorOptions needs a second (defaulted) type parameter — existing usage stays source-compatible. The options position needs NoInfer<TSource> so a wrong return type fails to compile instead of silently widening TSource inference (verified against the repo's TS 5.6–5.9 type tests).
What I'm not proposing: no changes to @tanstack/store core, no dehydrate/hydrate machinery, no atom-internal changes. getServerSnapshot is a React (useSyncExternalStore) concept, so I'd scope this to react-store only; if adapter parity is wanted (the way compare landed across adapters in #244), I'm glad to look at which adapters can meaningfully honor it as a follow-up.
And if the adapter someday moves off useSyncExternalStore (#262), this option is purely additive within the current implementation and doesn't constrain that direction.
Offer
I have this implemented on a branch already — the option itself, what I believe would be the repo's first SSR tests (renderToString + hydrateRoot, no new dependencies — react-dom is already a devDependency), a type test, and a changeset: https://github.com/bokeeeey/store/tree/feat/react-store-get-server-snapshot
It passes test:lib (49 tests), test:types across TS 5.6/5.7/5.8/5.9, test:eslint, build, and publint locally. If you're open to the idea I'll open the PR right away, and we'd validate the pkg.pr.new preview build against our production TanStack Start app.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
TL;DR
The React adapter's
useSelectorpasses the live client snapshot function as thegetServerSnapshotargument ofuseSyncExternalStoreWithSelector. Any store write that lands between the server render and hydration — alocalStoragesync effect, a module-init read, anything that runs before a code-split boundary hydrates — makes hydration render a value that no longer matches the server HTML, and React reports a hydration mismatch. There is currently no way for a consumer to provide the value the server actually rendered.I'd like to propose an opt-in
getServerSnapshotmember on the existingUseSelectorOptions, defaulting to today's behavior — and I'm happy to submit the PR.The defect
packages/react-store/src/useSelector.ts#L58-L66:React's contract for the third argument (react.dev): "It will be used only during server rendering and during hydration of server-rendered content on the client. The server snapshot must be the same between the client and the server." During hydration React renders whatever
getServerSnapshot()returns and reconciles it against the server HTML. Passing the livegetSnapshotis equivalent to asserting "the store has not changed since the server render" — which the library can't guarantee, and the consumer can't fix, because no API accepts a server snapshot. React's docs explicitly place this on the store library: "Your external store should provide instructions on how to do that."Since
useStore(deprecated alias),useAtom, and_useStoreall delegate touseSelector, this is a single choke point — one fix covers every React read path.Reproduction
Each step is ordinary app behavior:
theme = "light"(the store's initial value, chosen for SSR consistency)localStorage→store.setState(() => "dark")<Suspense>finishes loading and hydrates lateuseSelectorreturns"dark"while the server HTML says"light":I verified this with
renderToString+hydrateRoot(capturingonRecoverableError) under vitest/jsdom, on React 18.3.1 and 19.2.8, against@tanstack/react-store@0.11.0:hydrateRoot(module-initlocalStorageread) — no code splitting involvedcreateAtom+useAtomuseSyncExternalStorewith an honest server snapshotThe control row is the point: React's built-in mechanism handles this case exactly as designed — the library just doesn't expose it.
A runnable version of all of the above (minimal repro + the full scenario matrix, with React 18.3.1 and 19.2.8 variants) is here: https://github.com/bokeeeey/tanstack-store-ssr-repro —
pnpm install && pnpm test.Minimal repro test (drop into a vitest + jsdom project)
We hit this in production on a TanStack Start app: a theme store synced from
localStorageafter mount, and a late-hydrating chunk's theme toggle read the updated value during hydration. Our workaround is a mount gate — render the known server value until mounted — which costs an extra post-hydration re-render for something React supports natively.TanStack Router already needs this
Router's own SSR code routes around the missing server story in three ways today:
ClientOnly.tsx'suseHydrated()isReact.useSyncExternalStore(subscribe, () => true, () => false)— a hand-rolled honest server snapshot, reaching past@tanstack/react-storeto get oneuseRouterState.tsx,Scripts.tsx,headContentUtils.tsx, andMatches.tsxavoid calling the hook on the server entirely (isServerbranches guarded byeslint-disable react-hooks/rules-of-hooks)routerStores.tsswaps in non-reactive store factories on the server (createNonReactiveMutableStore)Meanwhile
@tanstack/react-formusesuseSelectorinternally with no server branching, and ships SSR examples (tanstack-start,remix,next-server-actions) — those are exposed to the same mismatch whenever form state changes before a boundary hydrates.Proposal
Extend the existing options bag — the same place
comparelanded via #243/#244:ssroption instead). Opt-in seems clearly right.serverStateon<Provider>for exactly this failure mode (reduxjs/react-redux#1835: "By the time a particular component is hydrated, the store might've already been mutated"), with the samegetServerState || store.getStatefallback shape. zustand's hook passes an honest third argument (getInitialState, pmndrs/zustand#2277). nanostores/react exposesssr: () => serverState.TSource, soUseSelectorOptionsneeds a second (defaulted) type parameter — existing usage stays source-compatible. The options position needsNoInfer<TSource>so a wrong return type fails to compile instead of silently wideningTSourceinference (verified against the repo's TS 5.6–5.9 type tests).What I'm not proposing: no changes to
@tanstack/storecore, no dehydrate/hydrate machinery, no atom-internal changes.getServerSnapshotis a React (useSyncExternalStore) concept, so I'd scope this toreact-storeonly; if adapter parity is wanted (the waycomparelanded across adapters in #244), I'm glad to look at which adapters can meaningfully honor it as a follow-up.And if the adapter someday moves off
useSyncExternalStore(#262), this option is purely additive within the current implementation and doesn't constrain that direction.Offer
I have this implemented on a branch already — the option itself, what I believe would be the repo's first SSR tests (
renderToString+hydrateRoot, no new dependencies —react-domis already a devDependency), a type test, and a changeset: https://github.com/bokeeeey/store/tree/feat/react-store-get-server-snapshotIt passes
test:lib(49 tests),test:typesacross TS 5.6/5.7/5.8/5.9,test:eslint,build, andpublintlocally. If you're open to the idea I'll open the PR right away, and we'd validate thepkg.pr.newpreview build against our production TanStack Start app.All reactions