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.isValidtrue when 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,
}))