Project Structure
Every feature is a self-contained domain folder under state/.
Folder layout
state/{domain}/
├── types.ts # State + Actions types
├── model.ts # model() with initial state
├── actions/
│ ├── crud.ts # CRUD operations
│ ├── load.ts # Optional coordinated or imperative fetching
│ ├── init.ts # Optional imperative initialization
│ └── ... # One file per concern
└── index.ts # create() hook + re-exports
Write order
Always create files in this order: types.ts → model.ts → actions/*.ts → index.ts
Start with the public contract, then its implementation. Components import the domain hook and
types from index.ts; actions import model definitions directly.
Rules
- Dependencies flow one way: pages → state → api
- Pass domain objects whole:
<Card post={post} />, not individual props - One domain per feature: don't mix concerns across domains
- Types first: the types file is the contract — JSDoc on each field guides implementation
types.ts
The types file defines two things: the state shape and the actions interface.
import type { Query } from '@comwit/state'
export type Post = { id: string; title: string; body: string }
export type PostComment = { id: string; postId: string; body: string }
export type PostState = {
posts: Query<Post[]> // keyed query data
comments: Query<PostComment[], string> // second generic = queryFn arg type
current: Query<Post | null, string> // hydrate the resolved Server Component value
}
export type PostActions = {
/** @description Imperatively fetch posts for coordinated workflows */
loadPosts(): Promise<void>
/** @description Create a post for the signed-in user */
create(title: string): Promise<void>
/** @description Toggle like. Optimistic update on list + current */
like(postId: string): Promise<void>
}
Query<Data>for keyed query fields — provides.data,.isLoading,.isError,.errorQuery<Data, Arg>— second generic is thequeryFnparameter typeLocal<Data, Arg>for an exact IndexedDB resource without a remote query function- Plain types for client-owned state
For view-owned requests, call s.posts.load(arg) for non-suspending client loading. When a Server
Component owns the resolved value, call usePost.hydrate(...) in a small client route adapter before
the actual UI reads the domain hook. Keep a loadPosts() action when fetching is part of a command,
multi-query sequence, preload, or user event.
Public entry point
// state/post/index.ts
import { create } from '@comwit/state'
import { post } from './model'
import { postActions } from './actions/interact'
import type { PostState, PostActions } from './types'
export const usePost = create<PostState, PostActions>(post, { actions: [postActions] })
export type { Post, PostState, PostActions } from './types'
Keep app-specific API calls under your api/ layer. A client domain may call a browser-safe API
adapter; server-only database code belongs behind a server boundary. Pass resolved server data
through a route adapter.
Cross-domain access
Actions can read other domains directly via state():
const postActions = action<Pick<PostActions, 'create'>, AppContext>(({ state }) => {
class Actions {
private post = state(post)
private user = state(user) // read from user domain
async create(title: string) {
if (!this.user.me) return
// ...
}
}
return new Actions()
})
State flows one way. No circular dependencies.