# comwit — AI implementation guide `@comwit/state` is domain-oriented state management for React and Next.js. This is the compact project contract. Follow the linked references for exhaustive APIs. ## Install and provider ```bash yarn add @comwit/state ``` Wrap the client tree once. Models remain lazy and are created only when accessed. Next.js owns request data and the HTML stream, so pass its functions to the provider instead of a framework wrapper: `getServerSearchParams` seeds `searchParam()` fields during SSR and `useServerInsertedHTML` streams server-resolved `.suspend()` results to the browser. Both are optional. ```tsx 'use client' import { useRouter, useServerInsertedHTML } from 'next/navigation' {children} ``` Enable `experimentalDecorators` when using decorators. ## Domain structure ```text state/{domain}/ types.ts model.ts actions/ load.ts crud.ts interact.ts index.ts ``` Write in that order. Keep related list, detail, stats, filters, and local UI state in one domain. Actions own commands, side effects, cross-domain access, and optimistic rollback. UI event handlers should normally call one action. ## Types and model ```ts import type { Local, Query } from '@comwit/state' export type ProductState = { products: Query detail: Query draft: Local selectedIds: string[] } export type ProductActions = { refresh(): Promise updateTitle(title: string): Promise } ``` ```ts import { local, model, query } from '@comwit/state' const products = local.collection({ key: 'products', version: 1 }) export const product = model({ products: local.query({ source: products, initialData: [], queryFn: (filter) => api.product.list(filter), }), detail: query({ initialData: null, queryFn: (slug) => api.product.detail(slug), }), draft: local({ source: products, initialData: null }), selectedIds: [], }) ``` - `Query`: remote query state. - `Query.Infinite`: cursor/infinite query. - `Query.Realtime`: initial query plus subscription. - `Local`: exact IndexedDB-backed resource without a query function. - `local.query()` / `local.infinite()`: query lifecycle plus normalized IndexedDB views. - Plain values: client-owned reactive state. ## Read, load, and hydrate queries ```tsx // Non-suspending. Returns isLoading immediately; starts queryFn after commit. const list = useProduct((state) => state.products.load(filter)) // Passive. Never starts a request. const cached = useProduct((state) => state.products) // Standalone local(). Exact IndexedDB restore; no API and no Promise throw. const draft = useProduct((state) => state.draft.restore(slug)) ``` Use `.load(arg)` when the mounted client component owns the request and renders `isLoading` / `isError` itself. For SSR data, execute and await the server function in a Server Component, pass the resolved seed only to a small client route adapter, and hydrate before the first normal domain hook read: ```tsx // Server Component async function ProductDetailLoader({ slug }: { slug: string }) { const initialDetail = await productApi.detail(slug) return } // Small Client Component route adapter 'use client' function ProductDetailRoute({ slug, initialDetail }: Props) { useProduct.hydrate({ detail: { arg: slug, data: initialDetail } }) return } // Actual UI reads the domain hook; it receives no server-data prop. function ProductDetail() { const detail = useProduct((state) => state.detail.data) if (!detail) return return } ``` Call `.hydrate(...)` unconditionally like a hook and before any normal domain-hook read for that model. It returns `void`, accepts `null` / `undefined` as a no-op, infers field, `arg`, and `data` types from query fields, and records freshness internally. New `query()` / `local.query()` entries initialize before their first snapshot. Equivalent repeated seeds are no-ops. A changed seed for an observed entry is applied only after the requesting render commits, so an abandoned transition cannot replace current state. It never invokes `queryFn`, and it does not use actions or public proxy mutation. ### Experimental selector Suspense `.suspend(arg)` is experimental. It starts or reuses `queryFn` during render and throws the keyed Promise while pending, so a `` boundary shows its fallback. It accepts only the query argument—never a Promise or initial-data override. ```tsx // Server Component: the boundary streams once the query resolves. }> // Client Component: the same selector renders on the server and hydrates in the browser. function ProductDetail({ id }: { id: string }) { const detail = useProduct((state) => state.detail.suspend(id)) return detail.data ? : } ``` With `useServerInsertedHTML` on the provider (available in `@comwit/state@2.5.0-beta.0`), a result resolved during the Next.js server render is inserted as an inert JSON script before that boundary's HTML. The hydrating `.suspend()` reads it, renders the identical snapshot, activates the entry after commit with the server's fetch time, and never calls `queryFn` again. Without the prop, the browser fetches again while hydrating. Requirements: `queryFn` runs inside the Client Component bundle on both server and browser. It cannot call a Next.js Server Function or import database code; use a Route Handler or external API with an absolute URL on the server. Results must be JSON-serializable. Keep the boundary inside the provider. Ordinary single/infinite queries and `local.query()` / `local.infinite()` are supported; realtime and streaming resources are not. Prefer `hydrate()` for data that must stay on the server. The descriptor option `suspense: true` and `Query.Suspense*` aliases are deprecated. They retain legacy behavior for compatibility. Query state: `data`, `isLoading`, `isFetching`, `isSuccess`, `isError`, `error`. Infinite resources also expose `cursor` and `hasMore`; realtime resources expose `connectionStatus` and `isConnected`. ## Actions Use `state(model)` inside actions. Query methods there are imperative: ```ts export const productActions = action(({ state }) => { const m = state(product) return { async refresh() { await Promise.all([m.products.refetch(), m.detail.refetch()]) }, async updateTitle(title) { const current = m.detail.data if (!current) return const before = { ...current } current.title = title try { await api.product.update(before.slug, { title }) } catch (error) { m.detail.set(before, { arg: before.slug }) throw error } }, } }) ``` - `.query(arg?, options?)`: imperative fetch; respects freshness. - `.refetch()`: force the active/last argument after an initial query. - `.set(data, { arg }?)`: replace data and mark success. - Infinite: `.nextFetch()` / `.previousFetch()`. - Realtime: `.unsubscribe()`. Use actions for events, preloads, forced requests, and multi-query coordination. Do not call a mutating action during render to initialize the same store. ## SSR safety Server-owned data has two paths. Await it in a Server Component, pass the resolved value only to the route adapter, and hydrate the query cache there; or render a streamed `` boundary with an isomorphic `.suspend(arg)` and `useServerInsertedHTML`. In both cases the actual UI reads the domain hook. Never initialize state by mutating it during render. React checks external-store snapshots before commit even when no subscriber fires, so a render-time mutation can discard and retry the tree repeatedly. Actions belong to events and imperative workflows. ## Local data Use `persist()` for browser-owned preferences. Use `local()` to restore exact server snapshots from IndexedDB without an API call. Use `local.query()` when the same exact view should revalidate from a server query. Resolved server values can initialize `local.query()` through the domain hook's `hydrate()` method. Standalone `local()` uses `.restore()` and is not a hydration target. Always pass a route identity to detail-resource operations. `local` is not an offline mutation queue or multi-device conflict resolver. Full storage, normalization, mapping, scope, and migration rules: https://library.comwit.io/state/llm/local.txt ## URL state Available in `@comwit/state@2.4.0`. Like `persist()`, `searchParam()` declares a model field whose normal value is synchronized automatically: ```ts import { action, model, searchParam } from '@comwit/state' const location = model({ threadId: searchParam({ key: 'thread' }), // string | null page: searchParam({ key: 'page', type: 'number', defaultValue: 1 }), // number archived: searchParam({ key: 'archived', type: 'boolean' }), // boolean | null query: searchParam({ key: 'q', parse: (raw) => raw.trim() }), }) const locationActions = action(({ state }) => { const current = state(location) return { selectThread(id: string | null) { current.threadId = id }, } }) ``` Read fields through ordinary `useModel()` or a generated domain hook. Normal action assignments write to the URL with `replace` by default; `history: 'push'` is opt-in. No separate connection hook. Valid parsed URL values win over optional `defaultValue`; missing/invalid values use that default or `null`. Initial defaults do not write the URL. Setting `null` removes the parameter. String is the default type; numbers must be finite and booleans parse true/false or 1/0. Custom structured formats require both `parse` and `serialize`; thrown parse errors use the fallback. For SSR, the existing Provider optionally accepts `getServerSearchParams?: () => string | null`. It reads request search data only on the server and transfers it to matching hydration internally. Without server search data, SSR/first hydration use fallbacks; client commit reads the browser URL. Back/forward updates state; selected value changes rerender normally, without a document reload. Keep domain validation and data fetching in application actions/queries. Async guards and full options: https://library.comwit.io/state/llm/search-param.txt ## Hook and proxy boundaries ```ts export const useProduct = create(product, { actions: [productActions], }) ``` ```tsx function SaveTitle({ title }: { title: string }) { const actions = useProduct((state) => state.actions) return } ``` Use narrow selectors. Convert reactive proxies before server actions or serialization: ```ts import { snapshot } from '@comwit/state' await save(this.m.snapshot()) await search(snapshot(this.m.filters)) ``` ## Other features - Derived state: `computed()` or model `derive` getters. - Validation: model `rules`; read `$validation`. - History: `history: true | { limit }`; use `$history.undo()`, `redo()`, and `ignore(fn)`. - Persistence: `persist({ key, defaultValue, storage? })`. - URL state: `searchParam({ key, type?, defaultValue?, parse?, serialize?, history? })`. - Decorators: `@OnError`, `@OnSuccess`, `@Authorized`, `@Debounce`, `@Throttle`, `@Retry`, `@Queue`, `@Log`, and `@Validate`. ## Delivery checklist - Query arguments and route identities are explicit. - `.load()` owns client fetching; `.hydrate()` initializes resolved server query values. - Experimental `.suspend()` is used only with a render-safe, isomorphic query function, and the provider receives `useServerInsertedHTML` so SSR results stream instead of refetching. - Passive selectors do not accidentally fetch. - Loading, error, empty, and background-fetch UX is defined. - No action or public proxy mutation initializes state during render. - CRUD keeps list/detail/stats consistent and rolls back failed optimistic writes. - Proxies are snapshotted at serialization boundaries. ## Full references - https://library.comwit.io/state/llm/query.txt - https://library.comwit.io/state/llm/local.txt - https://library.comwit.io/state/llm/decorator.txt - https://library.comwit.io/state/llm/persist.txt - https://library.comwit.io/state/llm/search-param.txt - https://library.comwit.io/state/llm/history.txt - https://library.comwit.io/state/docs Feedback: `gh issue create --repo burrr-ai/comwit --title '...' --body '...'`