Browse documentation

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

Add the provider once above the route.

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 ownerPattern
A mounted Client Component.load(arg) and render loading/error state
A Server ComponentAwait data, then useDomain.hydrate(...)
A user event or coordinated workflowAn action calling .query(arg) or .refetch()
An IndexedDB-only fallbackStandalone 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.

Suspense and older initialization patterns

Use a Server Component's Suspense boundary to stream a fallback around an async loader when needed. Experimental selector .suspend(arg) executes the query during render and requires a query function that is safe in every rendering environment. A Next.js Server Function cannot be called from a Client Component's initial render; await it on the server and hydrate instead.

Do not initialize state by calling an action in a render body. Deprecated silent() does not make that mutation safe. Move resolved server query data to hydrate() and leave normal actions for events and imperative workflows.