Browse documentation

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 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:

MethodWhereDescription
.load(arg?)SelectorNon-suspending load; starts after commit and returns loading state.
.suspend(arg?)SelectorExperimental. Start during render and suspend for the keyed query.
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)
.isFetchingbooleanTrue during any fetch
.isSuccessbooleanTrue after a successful fetch
.isErrorbooleanTrue after a failed fetch
.errorstring | nullError message if failed

Options

Set query defaults on a field or with ComwitProvider. Imperative .query(arg, options) calls may override freshness and placeholder settings.

OptionWhereDescription
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 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.