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
{{ message }}
Repository navigation
react-store: opt-in getServerSnapshot for useSelector#344
useSelector always passes the live snapshot as the third argument (getServerSnapshot) of useSyncExternalStoreWithSelector, so a consumer has no way to tell React which value the server rendered. I'd like to propose an opt-in getServerSnapshot member on UseSelectorOptions: default behavior unchanged, react-store only. I have an implementation ready if this direction works for you.
When this matters
The scope is narrow and specific to React 18+ selective hydration:
The server renders from store state (e.g. a cart count seeded from loader data), and the client store is seeded with the same value.
React hydrates the shell first, and an effect there writes to the store (a realtime update, reconciling with another tab, …).
A component inside a <Suspense> boundary that hydrates later reads the store through useSelector. Since the server snapshot is the live snapshot, it renders the new value against the old HTML → hydration mismatch, and React falls back to client rendering for that boundary.
Without the Suspense boundary, everything hydrates in one batch and nothing breaks. Seeding the client store correctly, or creating the store per request (useCreateStore / createStoreContext), doesn't prevent it either, because the write happens during hydration.
Client-only values (theme from localStorage, timezone) are not the motivation; those are better solved outside the store (cookies, client-only rendering). We first ran into this with exactly such a theme store in a TanStack Start app, which is how I traced the mechanism, but the case this proposal targets is server-seeded state.
Why at the binding level
React's docs put this on the store's binding: "Make sure that getServerSnapshot returns the same exact data on the initial client render as it returned on the server. […] Your external store should provide instructions on how to do that." (react.dev)
When this exact pattern was reported in facebook/react#22361, Andrew Clark answered: "This is the purpose of the getServerSnapshot argument. It looks like you're passing the same getServerSnapshot as you are the regular getSnapshot." The reporter, whose store was created per component, concluded: "Even though I know that the server store and initial client store are the same, I don't know if the client store is still unchanged once a particular reader is getting hydrated. So I need to make sure I read from the initial copy."
I know Query and Router avoid this differently: HydrationBoundary defers hydrating already-cached queries until after commit, and Start restores router state before the app tree renders. That works because they own the write path. With Store the writes come from user code, so the library can't reorder them. What it can do is let users fill the slot React reserves for this.
zustand: getInitialState() as the server snapshot (pmndrs/zustand#2277), on by default, per store
nanostores: opt-in ssr option on useStore (nanostores/react#40), per hook. They first changed the default and reverted it within days, because getServerSnapshot also runs on the server. That's why this proposal is opt-in.
Proposal
exportinterfaceUseSelectorOptions<TSelected,TSource=unknown>{compare?: (a: TSelected,b: TSelected)=>boolean/** * Snapshot used during server rendering and during hydration. * Should return the value the server render used. * Defaults to the live value (current behavior). */getServerSnapshot?: ()=>TSource}
Opt-in and additive. Without the option, behavior is unchanged.
Pre-selector value. It returns TSource and the selector is applied to it, the same shape as react-redux and zustand.
Runs on the server too. React calls it during server rendering as well, so it must return what the server actually rendered (or only be passed on the client). I'd document this.
React 18+ only. On React 16/17 the use-sync-external-store shim ignores this argument. That's harmless, since selective hydration doesn't exist there, but worth documenting.
Scope.react-store only (useAtom / _useStore inherit it). No core changes and no dehydrate/hydrate machinery. Whether octane-store should match is your call. If you'd prefer a store- or Provider-level variant (react-redux style) over a per-hook option, I'm happy to go that way instead.
Repro & implementation
Repro (React 18.3 and 19.2, renderToString + hydrateRoot, plus a control using raw useSyncExternalStore with an honest server snapshot): https://github.com/bokeeeey/tanstack-store-ssr-repro. It uses a plain string store; the mechanism doesn't depend on what the value represents.
The implementation (the option, SSR tests, a type test and a changeset) passes pnpm test:pr on current main. I'll open a PR if this direction works for you.
If you'd rather treat hydration consistency as an app/framework concern, I'd be glad to contribute an SSR section to the docs instead (per-request stores with createStoreContext, not writing to the store before late boundaries hydrate, a hand-rolled useSyncExternalStore recipe). The docs don't cover SSR today.
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.
Uh oh!
There was an error while loading. Please reload this page.
TL;DR
useSelectoralways passes the live snapshot as the third argument (getServerSnapshot) ofuseSyncExternalStoreWithSelector, so a consumer has no way to tell React which value the server rendered. I'd like to propose an opt-ingetServerSnapshotmember onUseSelectorOptions: default behavior unchanged,react-storeonly. I have an implementation ready if this direction works for you.When this matters
The scope is narrow and specific to React 18+ selective hydration:
<Suspense>boundary that hydrates later reads the store throughuseSelector. Since the server snapshot is the live snapshot, it renders the new value against the old HTML → hydration mismatch, and React falls back to client rendering for that boundary.Without the Suspense boundary, everything hydrates in one batch and nothing breaks. Seeding the client store correctly, or creating the store per request (
useCreateStore/createStoreContext), doesn't prevent it either, because the write happens during hydration.Client-only values (theme from
localStorage, timezone) are not the motivation; those are better solved outside the store (cookies, client-only rendering). We first ran into this with exactly such a theme store in a TanStack Start app, which is how I traced the mechanism, but the case this proposal targets is server-seeded state.Why at the binding level
React's docs put this on the store's binding: "Make sure that
getServerSnapshotreturns the same exact data on the initial client render as it returned on the server. […] Your external store should provide instructions on how to do that." (react.dev)When this exact pattern was reported in facebook/react#22361, Andrew Clark answered: "This is the purpose of the
getServerSnapshotargument. It looks like you're passing the samegetServerSnapshotas you are the regulargetSnapshot." The reporter, whose store was created per component, concluded: "Even though I know that the server store and initial client store are the same, I don't know if the client store is still unchanged once a particular reader is getting hydrated. So I need to make sure I read from the initial copy."I know Query and Router avoid this differently:
HydrationBoundarydefers hydrating already-cached queries until after commit, and Start restores router state before the app tree renders. That works because they own the write path. With Store the writes come from user code, so the library can't reorder them. What it can do is let users fill the slot React reserves for this.Prior art
<Provider serverState>(reduxjs/react-redux#1835), opt-in, per ProvidergetInitialState()as the server snapshot (pmndrs/zustand#2277), on by default, per storessroption onuseStore(nanostores/react#40), per hook. They first changed the default and reverted it within days, becausegetServerSnapshotalso runs on the server. That's why this proposal is opt-in.Proposal
TSourceand the selector is applied to it, the same shape as react-redux and zustand.use-sync-external-storeshim ignores this argument. That's harmless, since selective hydration doesn't exist there, but worth documenting.react-storeonly (useAtom/_useStoreinherit it). No core changes and no dehydrate/hydrate machinery. Whetheroctane-storeshould match is your call. If you'd prefer a store- or Provider-level variant (react-redux style) over a per-hook option, I'm happy to go that way instead.Repro & implementation
renderToString+hydrateRoot, plus a control using rawuseSyncExternalStorewith an honest server snapshot): https://github.com/bokeeeey/tanstack-store-ssr-repro. It uses a plain string store; the mechanism doesn't depend on what the value represents.pnpm test:pron currentmain. I'll open a PR if this direction works for you.If you'd rather treat hydration consistency as an app/framework concern, I'd be glad to contribute an SSR section to the docs instead (per-request stores with
createStoreContext, not writing to the store before late boundaries hydrate, a hand-rolleduseSyncExternalStorerecipe). The docs don't cover SSR today.All reactions