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: true on 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.products without calling .load(...) is passive and never starts a request.
  • load has no call-time options. Put defaults on query(...) or ComwitProvider; 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:

MethodWhereDescription
.load(arg?)SelectorNon-suspending load; starts after commit and returns loading state.
.suspend(arg?)SelectorExperimental. Start during render and suspend; streams from SSR with Next.js.
useDomain.hydrate()HookInitialize resolved server query values before the first model snapshot.
.query(arg?)ActionFetch data. Respects staleTime — skips if data is fresh.
.refetch()ActionForce re-fetch with the last used argument.
.set(data, { arg }?)ActionManually set data and mark success; arg establishes an exact resource identity.

Status flags

FlagTypeDescription
.dataTThe current data
.isLoadingbooleanTrue on first fetch (no prior success/error)
.isSlowLoadingbooleanInitial loading reached the declared threshold; otherwise false
.isFetchingbooleanTrue during any fetch
.isSuccessbooleanTrue after a successful fetch
.isErrorbooleanTrue after a failed fetch
.errorstring | nullError 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(), and local.infinite(). Every query has the same readonly boolean; omitting the option keeps it false and allocates no loading timer. Standalone local(), persist(), model options, provider defaults, and per-call query options do not accept this policy.
  • Accepts finite milliseconds from 0 through 2147483647; other values throw RangeError. 0 becomes true as soon as a browser query starts initial loading. At 500, 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 with isLoading: false / isSuccess: true without 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.

OptionWhereDescription
slowLoadingMsField onlyInitial loading threshold; query state exposes isSlowLoading
staleTimeField, provider, callFresh cache lifetime in ms; default 0
gcTimeField, provider, callCache retention after the last model observer leaves; default 5 * 60_000
placeholderDataField, provider, callLoading placeholder; use keepPreviousData to retain the previous result
forceCall onlyFetch even when cached data is fresh
enabledField only(modelState) => boolean; skip while false
dependsOnField only(modelState) => dependency; gate a request on another value or query
refetchIntervalField onlyFixed or data-dependent polling interval
streamBatchIntervalSingle/infinite fieldMinimum interval in ms between stream updates
suspenseSingle/infinite fieldDeprecated; 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

MethodDescription
.nextFetch()Fetch the next page. No-op when hasMore is false.
.previousFetch()Go back to the previous cursor.

Additional flags

FlagTypeDescription
.cursorstring | nullCurrent pagination cursor
.hasMorebooleanWhether 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:

CallbackSignatureDescription
update(updater: (prev: T) => T) => voidMerge data using updater function
set(data: T) => voidReplace data entirely
refetch() => voidRe-execute the queryFn
onStatus(status: ConnectionStatus) => voidUpdate connection status
onError(error: unknown) => voidSet error state

Additional methods

MethodDescription
.unsubscribe()Stop the live subscription. Sets status to 'disconnected'.

Additional flags

FlagTypeDescription
.connectionStatusConnectionStatus'connecting' | 'connected' | 'disconnected' | 'reconnecting'
.isConnectedbooleanTrue 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 until isSuccess is true
  • If the returned value is null or undefined — blocks the query
  • Any other value — allows the query; use enabled for 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.