model()
Creates a global reactive store. Each domain has one model.
import { model } from 'comwit'
Usage
import { model, query } from 'comwit'
import type { PostState } from './types'
export const post = model<PostState>({
posts: query<Post[]>({
initialData: [],
queryFn: () => api.post.findAll(),
}),
comments: query<Comment[], string>({
initialData: [],
queryFn: (postId) => api.comment.findAll(postId),
}),
current: null,
})
// With persist fields
import { model, query, persist } from 'comwit'
export const app = model<AppState>({
posts: query<Post[]>({ initialData: [], queryFn: () => api.post.findAll() }),
theme: persist({ key: 'theme', defaultValue: 'light' }),
current: null,
})
How it works
model(initial) takes an object of initial values and returns a Model<T>.
- Fields using
query()become client-fetched data with loading states - Fields using
persist()are automatically synced to storage - Plain fields (strings, objects, arrays, null) are reactive local state
- Models initialize lazily — only when first accessed via a hook
- Each model instance is scoped to the nearest
ComwitProvider
Derived State
Define computed values that update reactively when their dependencies change.
const cart = model(
{
items: [] as CartItem[],
coupon: null as Coupon | null,
},
{
derive: (state) => ({
subtotal: () => state.items.reduce((sum, i) => sum + i.price * i.qty, 0),
discount: () =>
state.coupon?.type === 'percent'
? state.subtotal * (state.coupon.value / 100)
: (state.coupon?.value ?? 0),
total: () => Math.max(0, state.subtotal - state.discount),
isEmpty: () => state.items.length === 0,
}),
}
)
- Derived values are lazily evaluated — computed on access
- Results update reactively when dependencies change
- Derived fields are read-only — throw on mutation attempt
- Derived fields can depend on other derived fields
- Accessed the same way as regular state in components and actions
Validation Rules
Attach validation rules to model fields. Validation state updates reactively as the model changes.
const profile = model(
{
name: '',
email: '',
},
{
rules: {
name: (v) => v.length > 0 || 'Name is required',
email: (v) => v.includes('@') || 'Invalid email format',
},
}
)
$validation.errors—{ name: null, email: 'Invalid email format' }(null = passed)$validation.isValid—truewhen all rules pass- Reactively updates when state changes
- No new hooks — accessed via existing
useModel
const { name, email, $validation } = useProfile()
// $validation.errors.name → null (valid)
// $validation.errors.email → 'Invalid email format'
// $validation.isValid → false
Options
The second argument to model() accepts optional configuration:
model(initial, {
derive?: (state) => ({ key: () => value }),
rules?: { field: (value) => true | 'error message' },
})
In actions
Access a model's state inside an action via state():
const postActions = action<PostActions>(({ state }) => {
class Actions {
private model = state(post)
async loadPosts() {
await this.model.posts.query()
}
}
return new Actions()
})
state(model) returns a mutable proxy — mutations trigger re-renders in subscribed components.
In components
Use the hook created by create() to read model state:
const { posts, current } = usePost((s) => ({
posts: s.posts,
current: s.current,
}))