SSR state transfer
@sigx/store/ssr is a composable that carries a state slice from the server render to the client hydration: you compute or fetch the data once on the server, ship it inside the page, and the store seeds itself from it on the client — no refetch. Call it inside a store setup, exactly like persist, passing the setup context and the { state, patch } pair from defineState:
import { defineStore } from '@sigx/store';
import { ssrState } from '@sigx/store/ssr';
const useTodos = defineStore('todos', (ctx) => {
const { state, signals, patch } = ctx.defineState({ items: [] as Todo[] });
ssrState(ctx, { state, patch });
return { ...signals };
});
On the server the store renders with whatever its actions put in state during the request; that final state is serialized into the page. On the client the same store seeds from the serialized value as it's created, so the first render matches the server's and nothing re-fetches.
Server: register, serialize at emit time
On the server, ssrState registers the slice's live state under the key store:<storeName> on the per-request render context. It does not snapshot eagerly: the entry is toJSON-deferred, so the snapshot is taken when the shell is emitted — after rendering. Mutations the store makes during the request (an action that fetches, a computed that fills in) therefore ship with their final values.
The core stateSerializationPlugin emits that registry into window.__SIGX_ASYNC__. It runs automatically under renderDocument (and the other document renderers), or you can add it explicitly:
import { createSSR, stateSerializationPlugin } from '@sigx/server-renderer';
// a plain SSRPlugin — pass it when you create the SSR pipeline
const ssr = createSSR({ plugins: [stateSerializationPlugin()] });
Detection of the server is duck-typed via the component instance's ssr helper — @sigx/store has no dependency on @sigx/server-renderer. A store created on the server outside a render context (no instance to detect) serializes nothing; it never falls through to the client path, so one request's state can't leak into another.
Client: seed on creation
On the client, ssrState seeds the slice from window.__SIGX_ASYNC__['store:<storeName>'] as one atomic patch() — a single reactivity flush over the defaults, so the first render is already populated.
By default the seed is shared: reading it doesn't consume it. The entry stays on window.__SIGX_ASYNC__, so every store instance created in the client runtime seeds from it — each getting its own copy of the value (via structuredClone; see Rich values for the fallback where that's unavailable). This is what a page needs whenever the same store is instantiated more than once: across @sigx/ssr-islands island roots or @sigx/resume boundaries, every instance hydrates from the server state instead of only the first one forking from the seed and the rest silently starting from defaults.
Client seeding only runs in a browser-like environment (window present); it never runs on the server. ssrState returns { hydrated }, true only when a server seed was actually applied (always false on the server):
const useTodos = defineStore('todos', (ctx) => {
const { state, signals, patch } = ctx.defineState({ items: [] as Todo[] });
const { hydrated } = ssrState(ctx, { state, patch });
// hydrated === true on the client when the page carried server state for
// this store; false on the server and on a cold client load.
return { ...signals, hydrated };
});
scope — shared across instances, or consume-once
scope decides what happens to the transfer entry once it's read:
ssrState(ctx, { state, patch }, {
scope: 'instance', // consume the seed on first read — default: 'shared'
});
'shared'(default) — the seed persists for the page lifetime and every instance of the store seeds from it, each with its own copy (viastructuredClone). Use it for runtime-wide state that more than one instance reads: across@sigx/ssr-islandsisland roots or@sigx/resumeboundaries a store now needs no extra wiring — every island or boundary hydrates from the same server state.'instance'— consume-once: the entry is removed as it's read, so a second instance of the same store starts from defaults. Use it for state that belongs to a single store instance rather than to the runtime as a whole.
Upgrading from consume-once? Earlier releases always consumed the seed on first read. If you relied on that — a second instance intentionally starting from defaults — pass
scope: 'instance'to keep the old behaviour.
Rich values cross the wire intact
Transferred slices round-trip rich types — Date, Map, Set, BigInt — not just JSON primitives. The read goes through core's blob accessors, the same codec useData uses, so a Date arrives on the client as a Date, not a tagged string.
Under the shared default each instance gets its own copy via structuredClone, which preserves those types. On a runtime without structuredClone, a value JSON can't represent exactly is shared by reference across instances — with a dev warning — rather than being flattened: handing a store a Date collapsed to a string is worse than sharing one.
pick — limit what crosses the wire
By default every key of the slice is serialized and seeded. pick narrows it to a subset — the same list governs both the server snapshot and the client seed:
ssrState(ctx, { state, patch }, {
pick: ['items'], // serialize + seed only these keys — default: all slice keys
});
Whatever the page carries, only keys that actually exist on the slice are ever applied; unknown keys and reserved/__proto__-style keys are filtered out on both ends, so a tampered or mismatched blob can never assign unexpected keys onto your reactive state.
Composing with persist()
ssrState and persist layer cleanly — server-rendered data first, then device-local overrides. Call ssrState first:
import { persist } from '@sigx/store/persist';
import { ssrState } from '@sigx/store/ssr';
const useTodos = defineStore('todos', (ctx) => {
const { state, signals, patch } = ctx.defineState({ items: [] as Todo[] });
ssrState(ctx, { state, patch }); // 1. synchronous seed from the server render
persist(ctx, { state, patch }); // 2. then device-local data, if any
return { ...signals };
});
The ordering is what makes it work: ssrState seeds synchronously as the store is created. persist's hydration — which may be async with stores like AsyncStorage — then overwrites with device-local data when present. So a returning visitor sees their saved state, while a first-time visitor keeps the server-rendered values. (See the hydration race for how persist orders its own writes.)
Relationship to useData / useStream
This is the store-shaped entry point to the same SSR state-transfer mechanism that core's useData and useStream use. They share one wire format — window.__SIGX_ASYNC__, a prototype-pollution-safe, page-lifetime blob the server renderer emits — and store:<storeName> is simply the store keyspace within it. Component-level fetches use useData; store-owned state uses ssrState. The server half of both is owned by @sigx/server-renderer's document renderers; see the data-loading guide for the component side and the server renderer guide for the document-rendering APIs.
Requirements
SSR state transfer needs the server renderer in the picture: sigx (or @sigx/server-renderer directly) on the server, rendering through renderDocument or a createSSR() pipeline with stateSerializationPlugin(). The store package itself adds no SSR dependency — on a purely client-rendered page ssrState is a no-op that returns { hydrated: false }, and the store renders from defaults.
The shared default, the scope option, and the rich-value round-trip need @sigx/store@0.11 or newer, which pins the 0.13.x core line (sigx / @sigx/reactivity / @sigx/runtime-core ^0.13.0).
Next steps
- Persistence — device-local state; composes with
ssrState. - Data loading (
useData/useStream) — the component-level half of the same SSR transfer. - Composables & Plugins — how setup composables like
ssrStateandpersistare built.
