Server data, domain state
Fetch in a Server Component, pass the resolved value to a small Client Component, then initialize
the domain with useDomain.hydrate(). The rest of the UI reads the same domain hook it uses for
client interactions.
Server Component → resolved data → client route adapter → domain hook → UI
For a query that should render inside a streamed <Suspense> boundary, the experimental
.suspend(arg) path runs the query during the server render
and streams its result to the browser cache. Both paths start with the provider below.
Provider setup
Next.js owns request data and the HTML stream, so the provider receives the framework functions it needs as props. Nothing else about the provider changes; both props are optional.
// app/providers.tsx
'use client'
import { ComwitProvider } from '@comwit/state'
import { useRouter, useServerInsertedHTML } from 'next/navigation'
import type { ReactNode } from 'react'
import { getServerSearchParams } from './get-server-search-params'
export function Providers({ children }: { children: ReactNode }) {
const router = useRouter()
return (
<ComwitProvider
context={{ router }}
getServerSearchParams={getServerSearchParams}
useServerInsertedHTML={useServerInsertedHTML}
defaultOptions={{ query: { staleTime: 30_000 } }}
>
{children}
</ComwitProvider>
)
}
// app/get-server-search-params.ts
import { workUnitAsyncStorage } from 'next/dist/server/app-render/work-unit-async-storage.external.js'
/** Request search for searchParam() fields. Prerendering has no request, so the browser initializes. */
export function getServerSearchParams(): string | null {
if (typeof window !== 'undefined') return null
const store = workUnitAsyncStorage.getStore()
return store?.type === 'request' ? store.url.search : null
}
| Prop | Server | Browser |
|---|---|---|
getServerSearchParams | Read once when the provider initializes; seeds searchParam() fields | Never called; hydration reads the transferred value |
useServerInsertedHTML | Inserts a JSON script with resolved .suspend() results ahead of each flushed chunk | No-op; hydrating selectors read those scripts |
The request store above is an internal Next.js module. It is the only synchronous way to read the
request URL from a Client Component's server render, so pin it per Next.js version and keep the
getter in one file. useServerInsertedHTML is a public export of next/navigation; the same slot
accepts any function with that signature that inserts the returned element before the next flush.
1. Define the query
The query function is used for later client loads or action-driven refetches. The initial server seed does not invoke it.
// state/product/index.ts
import { create, model, query } from '@comwit/state'
export type Product = { id: string; title: string; description: string }
const product = model({
detail: query<Product | null, string>({
initialData: null,
staleTime: 30_000,
queryFn: async (id) => {
const response = await fetch(`/api/products/${encodeURIComponent(id)}`)
if (response.status === 404) return null
if (!response.ok) throw new Error('Could not load product')
return (await response.json()) as Product
},
}),
})
export const useProduct = create(product, { actions: [] })
2. Fetch on the server
getProduct below is your server-side API or database function returning Product | null.
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation'
import { getProduct } from '@/api/products'
import { ProductRoute } from './product-route'
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const initialProduct = await getProduct(id)
if (!initialProduct) notFound()
return <ProductRoute id={id} initialProduct={initialProduct} />
}
3. Hydrate before the domain read
// app/products/[id]/product-route.tsx
'use client'
import { useProduct, type Product } from '@/state/product'
export function ProductRoute({ id, initialProduct }: { id: string; initialProduct: Product }) {
useProduct.hydrate({ detail: { arg: id, data: initialProduct } })
return <ProductDetail />
}
function ProductDetail() {
const product = useProduct((s) => s.detail.data)
if (!product) return null
return (
<article>
<h1>{product.title}</h1>
<p>{product.description}</p>
</article>
)
}
Call hydrate() unconditionally, like a hook, before the normal read of that model. It returns
void. The fresh entry is immediately successful, with isLoading and isFetching both false;
passive readers do not issue a duplicate request.
Hydration contract
// Argument queries require their declared arg.
useProduct.hydrate({ detail: { arg: id, data: initialProduct } })
// An argument-free Query<number> takes only data.
useDashboard.hydrate({ count: { data: 42 } })
// Optional seeds keep the call unconditional.
useProduct.hydrate(seed ?? null)
Only query fields are accepted, and TypeScript infers the field names, arguments, and data. Partial
entry maps are allowed. Plain state, realtime queries, and standalone local() cannot be hydrated.
Infinite queries accept their data value; this API does not accept a separate cursor/history seed.
Equivalent repeated seeds are no-ops. A new unread entry initializes before its first snapshot.
Changes to an observed entry apply in the requesting render's layout commit, so an abandoned
transition cannot replace the currently committed screen. Hydration also records freshness for
later .load() or .query() calls; .refetch() still forces a request.
Choose who owns the request
| Data owner | Pattern |
|---|---|
| A mounted Client Component | .load(arg) and render loading/error state |
| A Server Component | Await data, then useDomain.hydrate(...) |
A streamed <Suspense> boundary | .suspend(arg) with useServerInsertedHTML (experimental) |
| A user event or coordinated workflow | An action calling .query(arg) or .refetch() |
| An IndexedDB-only fallback | Standalone local() with .restore(arg) |
.load() does not start a request during SSR because its work begins after commit. A passive
read after hydration displays the supplied server data. Adding .load(arg) makes that component
an owner of subsequent client loading and freshness checks.
local.query() and local.infinite() accept the same hydration interface. Their
IndexedDB work and canonical entity reconciliation begin after commit. Server and browser
providers have separate memory; the explicit seed is what connects their initial renders.
Streaming Suspense (experimental)
.suspend(arg) runs queryFn during render and throws the pending Promise, so a <Suspense>
boundary shows its fallback while the server waits for the data. With useServerInsertedHTML on
the provider, the resolved result travels with the boundary's HTML and the browser hydrates the
same data without calling queryFn again. Available in @comwit/state@2.5.0-beta.0:
npm install @comwit/state@beta
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { ProductDetail } from './product-detail'
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductDetail id={id} />
</Suspense>
)
}
// app/products/[id]/product-detail.tsx
'use client'
import { useProduct } from '@/state/product'
export function ProductDetail({ id }: { id: string }) {
const product = useProduct((s) => s.detail.suspend(id))
if (!product.data) return <NotFound />
return (
<article>
<h1>{product.data.title}</h1>
<p>{product.data.description}</p>
</article>
)
}
server render: suspend(id) → queryFn → boundary HTML
↘ <script type="application/json" data-comwit-suspend> before that chunk
browser: hydrate boundary → suspend(id) reads the script → same snapshot, no queryFn → entry active after commit
The streamed entry keeps the server's fetch time, so staleTime applies to later .load() and
.query() calls as if the browser had fetched it. Each result is matched to the selecting hook's
position in the tree, so nested providers and repeated components stay isolated. Without the
provider prop, the server and browser caches are separate and the browser runs queryFn again
while hydrating; that is the same as today's behavior.
Use this path only when queryFn can run in both environments:
- It executes inside the Client Component bundle during SSR. It cannot import database code, and Next.js rejects Server Function calls during render. Call a Route Handler or an external API.
- On the server,
fetchneeds an absolute URL. Build the origin from an environment variable or a request-aware helper instead of writingfetch('/api/products'). - Results are transferred as JSON. Dates arrive as strings; convert them where the data is read.
- Keep the
<Suspense>boundary inside the provider. A server-side query failure renders the boundary's fallback and the browser retries the request with the same error boundary rules. renderToString, the Pages Router, realtime queries, and streamingAsyncIterablefetchers are not covered.
hydrate() remains the default for data that must stay on the server. Choose .suspend() for
public, cacheable reads whose loading state belongs to a Suspense boundary.
Do not initialize state by calling an action in a render body. Move resolved server query data to
hydrate() or a streamed .suspend() and leave normal actions for events and imperative workflows.