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 during render.
With useServerInsertedHTML on the provider, a result resolved during
the server render streams to the browser ahead of the boundary's HTML, and the hydrating selector
returns it without a second request. Without it, server and browser caches stay separate and the
browser fetches again while hydrating. See
Streaming Suspense.
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; streams from SSR with Next.js. |
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) |
.isSlowLoading | boolean | Initial loading reached the declared threshold; otherwise false |
.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 |
Slow initial loading
Declare slowLoadingMs on a query to read the library-owned isSlowLoading boolean. It does
not delay the request, data, Promise, or existing isLoading / isFetching flags.
const projects = model({
list: local.query<Project[]>({
source: projectRows,
initialData: [],
queryFn: () => api.project.list(),
slowLoadingMs: 500,
}),
})
const projects = useProjects((state) => state.list.load())
if (projects.isLoading && !projects.isSuccess) {
return projects.isSlowLoading ? <ProjectsSkeleton /> : null
}
if (projects.isSuccess) return <ProjectsList items={projects.data} />
if (projects.isError) return <LoadError error={projects.error} />
return null
isSlowLoading: false does not mean the data is ready. A successful empty result is ready;
inspect status rather than data truthiness. Success takes precedence over background errors for
local queries. Layout space and whether to show a skeleton remain application decisions.
- Supported declarations:
query(),query.infinite(),query.realtime(),local.query(), andlocal.infinite(). Every query has the same readonly boolean; omitting the option keeps it false and allocates no loading timer. Standalonelocal(),persist(), model options, provider defaults, and per-call query options do not accept this policy. - Accepts finite milliseconds from
0through2147483647; other values throwRangeError.0becomes true as soon as a browser query starts initial loading. At500, it stays false before 500ms and becomes true if initial loading still continues when the timer runs. - The clock starts when the browser query enters initial loading. Selector
.load()starts work after commit; its earlier synthetic pending/SSR snapshot is false. Disabled/dependent queries that do not start work have no clock. Server rendering starts no loading timers; server seeds and first hydration snapshots remain false. Experimental.suspend()starts no clock for its uncommitted, render-staged request. - IndexedDB restoration and a cache miss's subsequent fetch share one interval. Restored or
remotely successful data clears the flag immediately, even if durable write-through is still
finishing. Usable stale data and background revalidation never become slow initial loading.
isLoading: false, success, or error ends the interval. Stream chunks can mark usable data withisLoading: false/isSuccess: truewithout waiting for the stream to finish. - Changing serialized arguments or activating a different local scope resets the interval. Overlapping requests for the same key and internal retry/backoff keep a continuous interval; a settled error followed by a new query starts a new one. Stale requests cannot clear a newer interval. No request key, retry counter, or timer is needed in the component.
- Consumers of the same provider/query share the flag. The last model observer leaving pauses timer notifications; reobserving the still-pending query resumes using its elapsed time. An imperative-only query owns its clock until settlement or provider teardown. These rules affect only the additional boolean, not request cancellation or cache retention.
- The flag is reset for cache reads and excluded from IndexedDB persistence. There is no minimum display duration: completion clears it immediately. Timer scheduling is a lower bound, not a guarantee of a paint at an exact millisecond.
Options
Set query defaults on a field or with ComwitProvider. Imperative
.query(arg, options) calls may override freshness and placeholder settings.
| Option | Where | Description |
|---|---|---|
slowLoadingMs | Field only | Initial loading threshold; query state exposes isSlowLoading |
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 have separate caches. Pass Next.js useServerInsertedHTML to
ComwitProvider and each result resolved during the server render is
inserted as an inert JSON script before the chunk that used it. The hydrating .suspend() call
reads that script, renders the identical snapshot, activates the entry after commit with the
server's fetch time, and never calls queryFn. Results are matched to the selecting hook's tree
position, so the server and browser trees must line up as hydration already requires. Without the
prop, the browser runs queryFn again while hydrating.
Supported: value- or Promise-returning single/infinite queries and their local adapters, with
JSON-serializable results. Realtime and async-iterable fetchers are excluded. React 19 streaming
SSR is covered; legacy renderToString() and frameworks without an insertion hook 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.