query()
Declares a data-fetching field inside a model.
import { query } from '@comwit/state'
Use local.query() when the exact argument should restore from IndexedDB before
stale data revalidates in the background. Standalone local() is available for exact IndexedDB
restore without a query function.
Basic usage
import { model, query } from '@comwit/state'
export const post = model<PostState>({
posts: query<Post[]>({
initialData: [],
queryFn: () => api.post.findAll(),
}),
comments: query<Comment[], string>({
initialData: [],
queryFn: (postId) => api.comment.findAll(postId),
}),
})
The type in your state should be Query<Data> (or Query<Data, Arg> if the queryFn takes an argument):
import { Query } from '@comwit/state'
export type PostState = {
posts: Query<Post[]>
comments: Query<Comment[], string> // string = queryFn arg type
}
Load from a selector
Inside useModel() or a hook created by create(), .load(arg) is the default request-owning
selector method. It reports loading state immediately and starts the request after commit.
Use .load(arg) when the component renders its own loading state:
const list = useProduct((state) => state.products.load({ page, filter }))
if (list.isLoading) return <Skeleton />
if (list.isError) return <ErrorMessage message={list.error} />
return <ProductList products={list.data.items} />
The argument type is inferred from Query<Data, Arg>. Queries without an argument use .load().
load is explicit and lifecycle-managed:
- It reports
isLoading: trueon the first render, then starts the request after the component commits. This prevents an empty-state flash before an effect calls an action. - The serialized argument is the cache key. Changing it loads the new key; ordinary rerenders with the same key do not restart the request.
- Concurrent selector loads for the same resource and key share the in-flight request.
- Selecting
state.productswithout calling.load(...)is passive and never starts a request. loadhas no call-time options. Put defaults onquery(...)orComwitProvider; use an action with.query(...),.refetch(), or.set()for imperative workflows.
const useDashboard = create(dashboard, { actions: [dashboardActions] })
const total = useDashboard((state) => state.total.load()) // Query<number>
const list = useDashboard((state) => state.list.load({ page })) // Query<List, { page: number }>
const cached = useDashboard((state) => state.list) // passive read
Existing effect-driven actions remain supported. Use selector load when the request belongs to the mounted view; use action .query() when loading is part of a command, sequence, preload, or user event.
Hydrate resolved server data
Await data in a Server Component and pass it to a small client route adapter:
'use client'
function ProductRoute({ slug, initialDetail }: Props) {
useProduct.hydrate({ detail: { arg: slug, data: initialDetail } })
return <ProductDetail />
}
function ProductDetail() {
const detail = useProduct((state) => state.detail.data)
return detail ? <ProductView product={detail} /> : <NotFound />
}
Call hydrate() unconditionally before the normal domain read. It returns void, accepts nullable
input, and infers every field's argument and data. It initializes new unread entries before their
first snapshot without calling queryFn. Equivalent seeds are no-ops; changes to observed entries
apply only when the requesting render commits.
Supported fields: query(), query.infinite(), local.query(), and local.infinite(). Standalone
local(), realtime, and plain state are excluded. See the complete
Next.js example and hydration contract.
Experimental selector Suspense
.suspend(arg) remains experimental. It starts or reuses a keyed query Promise during render and
throws while pending. It accepts only the inferred query argument—never a Promise or initial-data
override. Use it only when queryFn can safely execute in every environment that renders the
Client Component. Next.js Server Functions cannot be called from a Client Component's initial
render, and server/browser provider caches remain separate unless the result is explicitly
hydrated.
Methods
Inside React selectors, a query exposes .load(arg?) and experimental .suspend(arg?). A hook
generated by create() also exposes .hydrate(entries). Once accessed via
state() in an action, it exposes these imperative methods:
| Method | Where | Description |
|---|---|---|
.load(arg?) | Selector | Non-suspending load; starts after commit and returns loading state. |
.suspend(arg?) | Selector | Experimental. Start during render and suspend for the keyed query. |
useDomain.hydrate() | Hook | Initialize resolved server query values before the first model snapshot. |
.query(arg?) | Action | Fetch data. Respects staleTime — skips if data is fresh. |
.refetch() | Action | Force re-fetch with the last used argument. |
.set(data, { arg }?) | Action | Manually set data and mark success; arg establishes an exact resource identity. |
Status flags
| Flag | Type | Description |
|---|---|---|
.data | T | The current data |
.isLoading | boolean | True on first fetch (no prior success/error) |
.isFetching | boolean | True during any fetch |
.isSuccess | boolean | True after a successful fetch |
.isError | boolean | True after a failed fetch |
.error | string | null | Error message if failed |
Options
Set query defaults on a field or with ComwitProvider. Imperative
.query(arg, options) calls may override freshness and placeholder settings.
| Option | Where | Description |
|---|---|---|
staleTime | Field, provider, call | Fresh cache lifetime in ms; default 0 |
gcTime | Field, provider, call | Cache retention after the last model observer leaves; default 5 * 60_000 |
placeholderData | Field, provider, call | Loading placeholder; use keepPreviousData to retain the previous result |
force | Call only | Fetch even when cached data is fresh |
enabled | Field only | (modelState) => boolean; skip while false |
dependsOn | Field only | (modelState) => dependency; gate a request on another value or query |
refetchInterval | Field only | Fixed or data-dependent polling interval |
streamBatchInterval | Single/infinite field | Minimum interval in ms between stream updates |
suspense | Single/infinite field | Deprecated; see experimental .suspend(arg) below |
// In an action: force an exact argument to refresh.
await this.model.posts.query({ page: 1 }, { force: true })
Arguments and cache identity
The model field and serialized argument identify a query entry. Plain object keys are sorted,
so { page: 1, filter: 'all' } and { filter: 'all', page: 1 } share a key. Use stable, JSON-shaped
arguments and include every input that changes the requested data.
Selector .load(arg) always treats its input as the argument. For imperative .query(), an object
containing only option names can be interpreted as call options; pass a second options object to
make the argument explicit:
// Query<Result, { enabled: boolean }> — a regular argument.
const result = useSearch((s) => s.result.load({ enabled: true }))
// Query<Result, { force: boolean }> — disambiguate from the force option.
await this.model.result.query({ force: true }, {})
Ordinary rerenders with the same key do not restart the request. Remounting, changing the key, or
calling an imperative query may check freshness again. A new argument creates an independent
cache entry; .refetch() targets the active/last argument.
queryFn context
queryFn receives (arg, context). context.state is the readonly state of this query field,
not the whole model:
posts: query<Post[]>({
initialData: [],
queryFn: (_, { state }) => {
// state.data, state.isLoading, etc.
return api.post.findAll()
},
})
Streaming results
Single and infinite queries accept an AsyncIterable. Single queries can yield a data value or a
query-state patch. Infinite queries yield an object with data and optional cursor / hasMore.
Values replace .data; accumulate chunks yourself when rendering a growing result:
answer: query<string, string>({
initialData: '',
async *queryFn(prompt) {
let text = ''
for await (const token of api.ai.stream(prompt)) {
text += token
yield { data: text, isLoading: false, isSuccess: true }
}
},
streamBatchInterval: 32,
})
const answer = useChat((s) => s.answer.load(prompt))
return <p aria-busy={answer.isFetching}>{answer.data}</p>
isFetching stays true until the iterable completes. Yield isLoading: false when the first
usable result is ready. A failed stream exposes the error while retaining the last data received.
Experimental .suspend() does not support streaming fetchers. Local queries also support
progressive enrichment.
query.infinite()
For infinite scroll / pagination. Use Query.Infinite<T> as the type.
import { Query } from '@comwit/state'
// types.ts
export type PostState = {
trending: Query.Infinite<Post[]>
}
// model.ts
import { query } from '@comwit/state'
export const post = model<PostState>({
trending: query.infinite<Post[]>({
initialData: [],
queryFn: (_, { state }) => api.post.trending(state.cursor),
}),
})
Additional methods
| Method | Description |
|---|---|
.nextFetch() | Fetch the next page. No-op when hasMore is false. |
.previousFetch() | Go back to the previous cursor. |
Additional flags
| Flag | Type | Description |
|---|---|---|
.cursor | string | null | Current pagination cursor |
.hasMore | boolean | Whether more pages exist |
Usage in actions
async loadMoreTrending() {
await this.model.trending.nextFetch()
}
The initial infinite query can also be declared with .load(arg?) in a React selector. Continue to call .nextFetch() and .previousFetch() from actions.
The queryFn return value can include cursor and hasMore to control pagination:
queryFn: async (_, { state }) => {
const res = await api.post.trending(state.cursor)
return {
data: res.posts,
cursor: res.nextCursor,
hasMore: res.hasNext,
}
}
query.realtime()
For real-time data with WebSocket/SSE subscriptions. Use Query.Realtime<T> as the type.
After the initial queryFn fetch succeeds, a subscribe function is called with callbacks to push live updates.
import { Query } from '@comwit/state'
// types.ts
export type ChatState = {
messages: Query.Realtime<Message[], string>
}
// model.ts
import { query } from '@comwit/state'
export const chat = model<ChatState>({
messages: query.realtime<Message[], string>({
initialData: [],
queryFn: (roomId) => api.chat.history(roomId),
subscribe: (callbacks) => {
const ws = new WebSocket(`wss://api.example.com/chat`)
ws.onmessage = (e) => {
callbacks.update((prev) => [...prev, JSON.parse(e.data)])
}
ws.onopen = () => callbacks.onStatus('connected')
ws.onclose = () => callbacks.onStatus('disconnected')
ws.onerror = (e) => callbacks.onError(e)
return () => ws.close()
},
}),
})
Subscribe callbacks
The subscribe function receives an object with these callbacks and must return a cleanup function:
| Callback | Signature | Description |
|---|---|---|
update | (updater: (prev: T) => T) => void | Merge data using updater function |
set | (data: T) => void | Replace data entirely |
refetch | () => void | Re-execute the queryFn |
onStatus | (status: ConnectionStatus) => void | Update connection status |
onError | (error: unknown) => void | Set error state |
Additional methods
| Method | Description |
|---|---|
.unsubscribe() | Stop the live subscription. Sets status to 'disconnected'. |
Additional flags
| Flag | Type | Description |
|---|---|---|
.connectionStatus | ConnectionStatus | 'connecting' | 'connected' | 'disconnected' | 'reconnecting' |
.isConnected | boolean | True when status is 'connected' |
Usage in actions
async connectChat(roomId: string) {
await this.model.messages.query(roomId) // fetches + starts subscription
}
disconnect() {
this.model.messages.unsubscribe()
}
Dependent Queries
enabled
Conditionally skip a query based on model state. The queryFn won't fire until enabled returns true.
const user = model({
isLoggedIn: false,
profile: query<Profile | null>({
initialData: null,
queryFn: () => api.user.profile(),
enabled: (state) => state.isLoggedIn,
}),
})
When isLoggedIn is false, calling profile.query() is a no-op. Once the flag changes and query() is called again, the fetch fires.
dependsOn
Block a query until another query (or any state) is resolved. Useful for chained data fetching.
const post = model({
detail: query<Post | null, string>({
initialData: null,
queryFn: (id) => api.post.findById(id),
}),
comments: query<Comment[], string>({
initialData: [],
queryFn: (postId) => api.comment.findAll(postId),
dependsOn: (state) => state.detail, // waits for detail.isSuccess
}),
})
dependsOn checks:
- If the returned value has
isSuccess(a query field) — waits untilisSuccessistrue - If the returned value is
nullorundefined— blocks the query - Any other value — allows the query; use
enabledfor a boolean condition
These options gate requests; they do not create a dependent fetch sequence. Pass the resolved ID
to comments.load(postId) or coordinate detail.query(postId) followed by comments.query(postId)
in an action.
refetchInterval
Auto-poll at a fixed or dynamic interval.
// Fixed interval (ms)
notifications: query({
initialData: [],
queryFn: () => api.notifications.list(),
refetchInterval: 5000, // poll every 5 seconds
})
// Dynamic — stop polling on error
notifications: query({
initialData: [],
queryFn: () => api.notifications.list(),
refetchInterval: (data, error) => {
if (error) return false // stop on error
return 5000
},
})
Set refetchInterval: false to disable polling.
Experimental Suspense behavior
function PostList() {
const posts = usePost((state) => state.posts.suspend())
return posts.data.map((post) => <PostCard key={post.id} post={post} />)
}
;<Suspense fallback={<PostSkeleton />}>
<PostList />
</Suspense>
A cache miss starts and stores the keyed query Promise during render, then throws it. React retries
with the resolved data. Initial failures go to an error boundary. Later refetches keep successful
data visible with isFetching and do not suspend again. A blocked enabled or dependsOn condition
returns the current state without throwing.
Pending keys are staged separately from the currently committed resource. They become active on commit, so abandoned renders do not overwrite the visible screen. Server and browser providers still have separate caches: use explicit hydration to carry resolved server data to the client.
Supported: value- or Promise-returning single/infinite queries and their local adapters. Realtime
and async-iterable fetchers are excluded. React 19 streaming SSR is covered; legacy renderToString()
and framework-specific cache behavior are outside this contract.
The descriptor option suspense: true and Query.Suspense* types are deprecated compatibility
APIs. New code uses .load() for client-owned fetching and hydrate() for resolved server data.
Use .suspend() only when its render-time execution fits your query function and framework.