# 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 '...'`