Browse documentation

Quickstart

Install, define a domain, and use its hook. These examples work with React 18 or 19; the provider placement below uses the Next.js App Router.

1. Install

npm i @comwit/state

If you're building with an agent, give it llms.txt. It contains the core setup and usage contract. You can also follow the steps below yourself.

2. Add the provider

// app/providers.tsx
'use client'

import { ComwitProvider } from '@comwit/state'
import type { ReactNode } from 'react'

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ComwitProvider defaultOptions={{ query: { staleTime: 30_000 } }}>{children}</ComwitProvider>
  )
}
// app/layout.tsx
import type { ReactNode } from 'react'
import { Providers } from './providers'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

In a React app, wrap your root component with ComwitProvider instead. Shared context, persistence, and global interceptors are optional; see Provider.

3. Define a domain

Start with a small counter. The state contract and action contract tell both TypeScript and your agent what the feature exposes.

// state/counter/types.ts
export type CounterState = { count: number }
export type CounterActions = { increment(): void; reset(): void }
// state/counter/model.ts
import { model } from '@comwit/state'
import type { CounterState } from './types'

export const counter = model<CounterState>({ count: 0 })
// state/counter/actions/interact.ts
import { action } from '@comwit/state'
import { counter } from '../model'
import type { CounterActions } from '../types'

export const counterActions = action<CounterActions>(({ state }) => {
  class Actions {
    private counter = state(counter)

    increment() {
      this.counter.count += 1
    }

    reset() {
      this.counter.count = 0
    }
  }
  return new Actions()
})
// state/counter/index.ts
import { create } from '@comwit/state'
import { counter } from './model'
import { counterActions } from './actions/interact'
import type { CounterState, CounterActions } from './types'

export const useCounter = create<CounterState, CounterActions>(counter, {
  actions: [counterActions],
})

4. Read state, call actions

// app/page.tsx
'use client'

import { useCounter } from '@/state/counter'

export default function CounterPage() {
  const { count, actions } = useCounter((s) => ({
    count: s.count,
    actions: s.actions,
  }))

  return (
    <main>
      <p>Count: {count}</p>
      <button onClick={actions.increment}>Add one</button>
      <button onClick={actions.reset}>Reset</button>
    </main>
  )
}

The action changes the object. The selector subscribes to count. Each provider owns an independent counter, so separate app roots and server requests do not share a singleton state instance.

5. Add server data when you need it

Use a query field and call .load() from the view that owns the request. Here /api/products is your app's endpoint returning Product[]:

// state/product/index.ts — a compact domain; split files as it grows
import { create, model, query } from '@comwit/state'

type Product = { id: string; title: string }

const product = model({
  list: query<Product[]>({
    initialData: [],
    queryFn: async () => {
      const response = await fetch('/api/products')
      if (!response.ok) throw new Error('Could not load products')
      return (await response.json()) as Product[]
    },
  }),
})

export const useProduct = create(product, { actions: [] })
'use client'

import { useProduct } from '@/state/product'

export function ProductList() {
  const list = useProduct((s) => s.list.load())

  if (list.isLoading) return <p>Loading products…</p>
  if (list.isError && !list.isSuccess) return <p>{list.error}</p>
  if (list.data.length === 0) return <p>No products yet.</p>

  return (
    <ul aria-busy={list.isFetching}>
      {list.data.map((product) => (
        <li key={product.id}>{product.title}</li>
      ))}
    </ul>
  )
}

.load() returns loading state during render and starts the request after commit. Selecting s.list alone is a passive read. For data already fetched by a Server Component, follow the Next.js guide and use useProduct.hydrate(...).

Next steps

  • Project Structure — organize domains and their public contracts.
  • query() — arguments, pagination, streaming, and subscriptions.
  • local() — restore normalized IndexedDB data before revalidation.

Migrating from comwit? Version 2 uses the @comwit/state package name. See the v2.0.0 release notes.