@comwit/state

中文文档
2.7.0发布于 2026/11/08MITTypeScriptESM + CJS近 30 天下载 2089

React state management for vibe coding. Less tokens. Built for Claude Code.

原始使用文档
文档目录12 节
  1. Why comwit?
  2. Installation
  3. Working with an AI agent
  4. Quick start
  5. Beyond client state
  6. Queries and Next.js
  7. Seed a detail from a list item
  8. Slow initial loading
  9. URL state
  10. Documentation
  11. Migrating from comwit
  12. License

@comwit/state

React state for you and your agent.

State management for React and Next.js, published as @comwit/state. Keep client state, server queries, and actions in one domain, then use them through one typed hook.

Website · Documentation · Quickstart · npm

Used in 1,000+ projects on comwit.io, a Claude Code-powered vibe coding platform from Korea. Sponsored by burrr.ai.

Why comwit?

  • One domain, one hook. Define a model, add actions, and combine them with create().
  • Client state and server data together. Reactive values, cached queries, and server hydration share the same domain.
  • Direct mutations inside actions. Update state with ordinary JavaScript; components subscribe through selectors.
  • Built for coding agents. A compact llms.txt covers setup, domain structure, and the core APIs.

Installation

npm install @comwit/state

Supports React 18 and 19. For Next.js, follow the App Router setup.

Working with an AI agent

Give Claude Code or your coding agent this prompt:

Set up @comwit/state in this project using https://library.comwit.io/state/llms.txt.
Follow its domain structure and state management patterns.

Quick start

A complete counter in one file:

'use client'

import { action, ComwitProvider, create, model } from '@comwit/state'

type CounterState = { count: number }
type CounterActions = { increment(): void; reset(): void }

const counter = model<CounterState>({ count: 0 })

const counterActions = action<CounterActions>(({ state }) => {
  const m = state(counter)

  return {
    increment() {
      m.count += 1
    },
    reset() {
      m.count = 0
    },
  }
})

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

function Counter() {
  const { count, actions } = useCounter((s) => ({ count: s.count, actions: s.actions }))

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

export default function App() {
  return (
    <ComwitProvider>
      <Counter />
    </ComwitProvider>
  )
}

Each ComwitProvider owns its state, and models initialize only when accessed. As the app grows, split models, actions, and hooks into domain folders.

Beyond client state

What you needAPI / guide
Render server data with SSR and Suspense.suspend(arg)
Fetch and cache data in the browserquery() + .load(arg)
SSR alternative without useServerInsertedHTMLuseDomain.hydrate(...)
Restore an exact IndexedDB view without an API requestlocal() + .restore(arg)
Keep IndexedDB views and revalidate from the serverlocal.query() / local.infinite()
Persist browser-owned preferencespersist()
Derive values from statecomputed()
Undo and redo state changes$history
Add retries, debounce, or shared error handling to actionsAction decorators

Queries and Next.js

Read server data with .suspend(arg) in the Client Component that renders it, inside <Suspense>:

'use client'

function ProductDetail({ id }: { id: string }) {
  const product = useProduct((s) => s.detail.suspend(id))
  return product.data ? <h1>{product.data.title}</h1> : null
}
<Suspense fallback={<ProductSkeleton />}>
  <ProductDetail id={id} />
</Suspense>

Pass Next.js useServerInsertedHTML to the provider once. The server render runs queryFn and streams the result into the browser cache, so hydration renders it without a second request. There is no Server Component fetch and no route adapter.

'use client'

import { useServerInsertedHTML } from 'next/navigation'

<ComwitProvider useServerInsertedHTML={useServerInsertedHTML}>{children}</ComwitProvider>

queryFn runs on both server and browser, so call an API with an absolute URL and return JSON data.

Use .load(arg) when the component renders its own loading state. It reports isLoading during render and starts the request after commit, never during SSR:

const list = useProduct((s) => s.list.load({ page }))
if (list.isLoading) return <Skeleton />

Reading query state without .load() or .suspend() does not start a request.

In a framework whose provider has no useServerInsertedHTML (or an equivalent), a server response cannot reach the client cache, so client navigation requests it again. There, fetch on the server and pass the result to useProduct.hydrate({ detail: { arg: id, data: initialProduct } }) before the domain hook reads it. Next.js does not need this. See the Next.js guide.

Seed a detail from a list item

A detail opened from a list can paint from the list item at once and fill in the rest after commit. Declare the seed shape as the third generic of query() and derive it from the model with seed; the same .suspend(id) then serves a direct visit (suspends, streams) and an in-app navigation (paints the seed, upgrades in the background):

const catalog = model({
  list: query<ProductSummary[]>({ initialData: [], queryFn: api.product.list }),
  detail: query<ProductDetail | null, string, ProductSummary>({
    initialData: null,
    queryFn: (id) => api.product.find(id),
    seed: (id, { state }) => state.list.data.find((item) => item.id === id),
  }),
})
const detail = useCatalog((s) => s.detail.suspend(id))
if (!detail.data) return <ProductNotFound />
if (detail.isPartial) return <Product summary={detail.data} /> // detail-only fields are optional
return <Product summary={detail.data} reviews={detail.data.reviews} />

The result is a union discriminated on isPartial. While the key shows its seed, data is ProductSummary & Partial<ProductDetail>, isPartial is true, isLoading/isSuccess are false, and isFetching is true until the upgrade settles. A full result (isPartial: false) has data: ProductDetail | null. Queries without a seed keep their existing result type.

Rules:

  • seed runs during render, so it must be pure: read state (the model's own state, a read-only snapshot), never write. Return null/undefined when the model holds nothing for the key. state is typed any because query() is declared before its model exists; annotate the context parameter with QuerySeedContext<CatalogState> to type it.
  • TSeed may only name fields of the full value, with the same types; extra keys are a type error.
  • With a seed, .suspend(id) does not suspend and .load(id) does not report isLoading; both paint the seed and start the upgrade after commit. With neither seed nor cache, .suspend(id) suspends and streams exactly as before.
  • A full result for the key always wins over its seed and is never downgraded: a fetch, .set(), hydrate() and a streamed server result are all full results.
  • staleTime is ignored while a key is partial: the upgrade always runs.
  • Upgrade rejected: the seed stays, isError: true, isPartial: true (refetch() retries). Upgrade resolved to null: data === null, isPartial: false, so a !data branch renders not-found.
  • Seeds are per key. Changing the argument never shows another key's seed, and several mounted readers each paint their own key.

local.query() takes the same seed through the same third generic. Local resources are read with .load(id) (local() with .restore(id)); .suspend() is not available on them, as a type error and as a thrown error for untyped callers, because suspending would bypass the durable restore. With a seed, .load(id) paints it synchronously (isPartial: true, never isLoading), the IndexedDB view stored for the key then replaces it as a full value (isPartial: false, like hydrate()), and the network revalidates per staleTime. Without a stored row the seed stays until the network result. A key already restored in this provider never shows its seed again.

Reading another model (experimental): the seed context also offers read(model) for a seed whose source lives in another domain's model:

seed: (slug, { state, read }) =>
  state.list.data.find((p) => p.slug === slug) ??
  read(lounge)?.overview.data?.blogs.find((b) => b.slug === slug)

The recommended design keeps one model per resource (a page that needs several resources calls each resource's hook), so a detail's seed source normally lives in its own model and state is enough. read runs at render time and never initializes, mounts or observes the model it is given: until something else has registered that model in the provider it returns undefined and the key loads or suspends normally. It returns the same read-only snapshot state is (no .load(), no fetch, writes throw). Seeds are not subscribed to it: they are evaluated from the current snapshots when the key is read, so a later change to the other model does not re-seed a key that already painted.

seed differs from placeholderData: a placeholder keeps another key's full result visible while a new key loads; a seed is the same key's partial value. Infinite and realtime resources do not take a seed.

Slow initial loading

Set slowLoadingMs: 500 on query() or local.query() and read isSlowLoading through the usual query state. Existing loading flags, requests, cache freshness, and data delivery keep their timing. The new flag stays false for fast completion and usable cached data, becomes true for a continuing initial load, and clears immediately on success/error. No component, effect, or request key is needed. See the query guide for rendering, SSR, retries, cleanup, and the supported query variants. This is a query declaration option, not a model/provider default or a standalone local() option.

URL state

searchParam() declares typed URL-backed fields directly in a model. Read them through the same domain hook and update them through actions.

npm install @comwit/[email protected]

See the URL state reference for parsing, defaults, browser history, and server rendering.

Documentation

Start at library.comwit.io.

Migrating from comwit

Since v2.0.0, the package is named @comwit/state. Replace the dependency and update imports:

- import { model, action } from 'comwit'
+ import { model, action } from '@comwit/state'

The rename did not change the API. The deprecated comwit package re-exports @comwit/state for compatibility. See the v2.0.0 migration notes.

License

MIT

版本与发布记录

@comwit/state 共发布 29 个版本,首次发布于 2026/07/03;过去 12 个月发布了 29 个版本。

版本发布日期距今
2.7.0latest2026/11/086 天前
2.7.0-beta.02026/11/086 天前
2.6.02026/11/076 天前
2.6.0-beta.12026/11/076 天前
2.6.0-beta.02026/11/077 天前
2.5.22026/10/3015 天前

关于 @comwit/state 的常见问题

@comwit/state 是什么?

React state management for vibe coding. Less tokens. Built for Claude Code.

如何安装 @comwit/state?

在项目目录运行 npm install @comwit/state,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^18.0.0 || ^19.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@comwit/state 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@comwit/state 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@comwit/state 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@comwit/state 可以商用吗?

@comwit/state 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@comwit/state 的下载量有多少?

根据 npm 官方下载统计,@comwit/state 在 2026/10/11 – 2026/11/09 这 30 天内被下载 2,089 次,日均约 70 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@comwit/state 还在维护吗?

最新版本 2.7.0 发布于 2026/11/08(6 天前);至今共发布 29 个版本,其中过去 12 个月发布了 29 个;首次发布于 2026/07/03。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。