# comwit — AI implementation guide `@comwit/state` is domain-oriented React/Next.js state management. Use this file as the practical project contract; follow the linked references for exhaustive signatures. ## Install and provider ```bash yarn add @comwit/state ``` Enable `experimentalDecorators` in `tsconfig.json` when using decorators. Wrap the client tree once: ```tsx 'use client' {children} ``` Provider `context` is available to actions and lazy interceptors. Query defaults may also include `placeholderData`. ## Project contract ```text state/{domain}/ types.ts # State and Actions contracts; write first model.ts # model(), query(), persist(), computed() actions/ init.ts # server-data initialization load.ts # imperative/coordinated fetching crud.ts # mutations, optimistic updates, rollback interact.ts # domain interactions index.ts # create() domain hook and re-exports ``` Write in this order: `types.ts` → `model.ts` → `actions/*` → `index.ts`. Rules: - Keep list, detail, stats, filters, and related local UI state in the same domain when they must stay consistent. - Put commands and side effects in actions. UI event handlers should usually call one action. - Actions read other domains with `state(otherModel)` instead of receiving duplicate state as arguments. - Pass domain objects whole to UI (``). - After CRUD, update every related view optimistically or refetch it; include rollback for failed optimistic writes. - Use selectors; calling a domain hook without a selector observes the whole model. ## types.ts Use `Query` for remotely loaded fields and ordinary types for local/server-initialized state. ```ts import type { Query } from '@comwit/state' export type ProductState = { products: Query, { page: number; filter: ProductFilter }> stats: Query detail: Query selectedIds: string[] isEditMode: boolean } export type ProductActions = { initDetail(product: Product): void refreshAll(): Promise loadMore(): Promise create(title: string): Promise delete(id: string): Promise toggleSelect(id: string): void } ``` Query type variants: - `Query` — single resource - `Query.Infinite` — cursor/infinite resource - `Query.Realtime` — initial fetch plus subscription - `Query.Suspense` / `Query.SuspenseInfinite` — non-null data typing with `{ suspense: true }` ## model.ts The model defines data shape and fetch policy, not UI effects. ```ts import { keepPreviousData, model, query } from '@comwit/state' export const product = model({ products: query({ initialData: EMPTY_PAGE, queryFn: ({ page, filter }) => api.product.list({ page, filter }), placeholderData: keepPreviousData, }), stats: query({ initialData: EMPTY_STATS, queryFn: () => api.product.stats() }), detail: query({ initialData: null, queryFn: ({ id }) => api.product.get(id) }), selectedIds: [], isEditMode: false, }) ``` Use `keepPreviousData` mainly for pagination. Each serialized query argument has a cache entry; the resource state displays one active argument at a time. ### Infinite and realtime ```ts posts: query.infinite({ initialData: [], queryFn: async (_, { state }) => { const res = await api.posts.list({ cursor: state.cursor }) return { data: res.items, cursor: res.nextCursor, hasMore: res.hasNext } }, }) ``` `query.realtime()` also requires `subscribe(callbacks) => cleanup`; callbacks are `update`, `set`, `refetch`, `onStatus`, and `onError`. ### Derived state, validation, history, persistence - Inline derived fields: `computed(state => value)`. - Model option `derive`: returns named getter functions; derived values are readonly. - Model option `rules`: field validators; read `{ errors, isValid }` from `$validation`. - Model option `history: true | { limit }`: read `$history.undo()`, `redo()`, `canUndo`, `canRedo`, `ignore(fn)` through the existing domain hook. One action call is one history transaction. - `persist({ key, defaultValue, storage? })`: local/session/custom storage, SSR-safe, debounced, cross-tab for localStorage. ## Reading and starting queries A selector can start a request or passively read data that was already loaded or initialized: ```tsx const products = useProduct((s) => s.products.load({ page, filter })) // start/load this key const stats = useProduct((s) => s.stats) // read current state; never starts a request ``` Use selector `.load(arg?)` when the view owns the initial request. Its argument is inferred from `Query`, and it returns the ordinary query resource state. Use passive selection when another action or server initialization owns loading. Do not create another query hook. Inside actions, use `.query(arg?, options?)`, `.refetch()`, or related methods for user events, preloads, coordinated requests, and forced calls: ```ts export const loadActions = action>(({ state }) => { const m = state(product) return { async refreshAll() { await Promise.all([m.products.refetch(), m.stats.refetch()]) }, } }) ``` Existing `useEffect(() => actions.load(), [])` code remains supported; migrate to selector `.load()` only when the view clearly owns the request. See https://library.comwit.io/llm/query.txt for cache-key, lifecycle, infinite, realtime, and Suspense details. ## Query state and methods State: - All: `data`, `isLoading`, `isFetching`, `isSuccess`, `isError`, `error`. - Infinite: `cursor`, `hasMore`. - Realtime: `connectionStatus`, `isConnected`. Selector method: - `.load(arg?)` — lifecycle-managed initial query; returns state. Action methods from `state(model)`: - `.query(arg?, options?)` — fetch; honors cache freshness. - `.refetch()` — force the active/last argument; no-op before a successful initial query. - `.set(data | statePatch)` — set data and mark success; useful for optimistic and server initialization. - Infinite: `.nextFetch(arg?)`, `.previousFetch(arg?, options?)`. - Realtime: `.unsubscribe()`. Options: `staleTime`, `gcTime`, `placeholderData`; call-time `force`; conditions `enabled`, `dependsOn`; polling `refetchInterval`; `suspense`. `isLoading` represents a first load, while `isFetching` also covers background refetches. Render errors before data when appropriate. ## actions/\* Actions own mutation, I/O coordination, navigation, toast/dialog side effects, and cross-domain access. ```ts export const crudActions = action>(({ state }) => { class CrudActions { private m = state(product) @OnError((e) => toast.error(toMessage(e))) async delete(id: string) { if (!(await popup.confirm({ title: 'Delete this product?' }))) return const before = [...this.m.products.data.items] this.m.products.data.items = before.filter((item) => item.id !== id) try { await api.product.delete(id) await Promise.all([this.m.products.refetch(), this.m.stats.refetch()]) } catch (error) { this.m.products.data.items = before throw error } } } return new CrudActions() }) ``` Plain state is mutable inside actions: assign fields, `push`/`splice` arrays, or replace arrays with `filter`. Direct query-data mutation is also reactive; use `.set()` when replacing/initializing an entire query result and success state together. ## Server data and App Router Suspense No separate `hydrate()` API is needed. Initialize during client render through an action that wraps writes in `silent()`; do not delay it to an effect. ```ts initDetail(value: Product) { silent(() => this.m.detail.set(value)) } ``` `.set()` marks the query successful, so client fallbacks can distinguish initialized cache from `initialData`. ```tsx function DetailInit({ value }: { value: Product }) { const init = useProduct((s) => s.actions.initDetail) init(value) return } ``` For a server `` boundary, the client fallback may passively read the current query: show cached content when `isSuccess`, otherwise show a skeleton. The resolved server branch renders `DetailInit`. Reuse cached content only when its active identity is valid for the route; otherwise keep the fallback neutral. Library `suspense: true` is available for client query suspension: initial query promises suspend, refetches use `isFetching`, and errors go to the nearest error boundary. Prefer the server-streaming pattern above for App Router server work. ## index.ts and UI ```ts export const useProduct = create(product, { actions: [initActions, loadActions, crudActions, interactActions], }) ``` Selectors use deep equality and can return state plus actions: ```tsx const { list, actions } = useProduct((s) => ({ list: s.products.load({ page, filter }), actions: s.actions, })) if (list.isLoading) return if (list.isError) return return list.data.items.map((product) => ) ``` For passive cached/initialized state, omit `.load()`. UI handlers call actions rather than mutating state directly. ## Proxy boundaries `state(model)` is a reactive proxy. Convert it before server actions, structured serialization, or APIs that require plain extensible objects: ```ts import { snapshot } from '@comwit/state' await save(this.m.snapshot()) // top-level model await search(snapshot(this.m.filters)) // nested slice ``` - `snapshot(proxy)` returns a frozen plain snapshot and throws for non-proxies. - `isProxy(value)` detects comwit proxies. - `silent(fn)` suppresses subscriber notifications; it is not a history-ignore helper. ## Decorators and custom interception Built-ins: - `@OnError(fn)` — side effect, then the original error propagates automatically. - `@OnSuccess(fn)` — success side effect. - `@Authorized({ when, onDeny })` — guard. - `@Debounce(ms)`, `@Throttle(ms)` — timing; helpers can flush/cancel pending calls. - `@Retry(count, options?)` — fixed/exponential retry. - `@Queue('drop' | 'queue' | 'replace')` — concurrency. - `@Log(level?)`, `@Validate(validators)`. Use `intercept(hooks)` for context-free decorators and `intercept(({ state, context }) => hooks)` for lazy state/context access. Class decorators wrap every method; method decorators add narrower rules. If an `intercept` hook does not call `execute(...args)`, the original method is blocked. Create reusable decorators for repeated action rules, then apply them to a whole action class or one method: ```ts import { intercept } from '@comwit/state' const LoginRequired = intercept(({ state, context }) => { const user = state(userModel) return { intercept: (execute, args) => { if (!user.me) return context.router.push('/login') return execute(...args) }, } }) const ConfirmDelete = intercept(() => ({ intercept: async (execute, args) => { if (!(await popup.confirm({ title: 'Delete this product?' }))) return return execute(...args) }, })) @LoginRequired class CrudActions { async create(title: string) { /* ... */ } @ConfirmDelete async delete(id: string) { /* ... */ } } ``` Here `@LoginRequired` guards every method, while `@ConfirmDelete` applies only to `delete`. Class and method decorators can be combined; keep error side effects in built-ins such as `@OnError`. ## Delivery checklist - Types written before model/actions; query argument types are explicit. - View-owned query uses selector `.load(arg)`; passive query intentionally omits it. - Command/coordinated query stays in an action. - Loading, error, empty, and background-fetch behavior are deliberate. - Server init uses `silent()` and query `.set()` where applicable. - CRUD keeps list/detail/stats consistent and rolls back failed optimistic writes. - Proxy values are snapshotted at serialization boundaries. - Decorator support is enabled in TypeScript/Next.js. ## Full references - https://library.comwit.io/llm/query.txt — selector loading, caching, infinite, realtime, Suspense - https://library.comwit.io/llm/decorator.txt — decorators and `intercept` - https://library.comwit.io/llm/persist.txt — persistence - https://library.comwit.io/llm/history.txt — undo/redo - https://library.comwit.io/docs — human-readable documentation Feedback: `gh issue create --repo burrr-ai/comwit --title '...' --body '...'`