query()

Declares a client-side data fetching field inside a model.

import { query } from 'comwit'

Basic usage

import { model, query } from 'comwit'

export const post = model<PostState>({
  posts: query<Post[]>({
    initialData: [],
    queryFn: () => api.post.findAll(),
  }),
})

The type in your state should be Query<Data> (or Query<Data, Arg> if the queryFn takes an argument):

import { Query } from 'comwit'

export type PostState = {
  posts: Query<Post[]>
  comments: Query<Comment[], string> // string = queryFn arg type
}

Methods

Once accessed via state() in an action, query fields expose these methods:

MethodDescription
.query(arg?)Fetch data. Respects staleTime — skips if data is fresh.
.refetch()Force re-fetch with the last used argument.
.set(data)Manually set data (for optimistic updates).

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

Pass options at the query definition or globally via ComwitProvider:

OptionDescription
staleTimeTime in ms before cached data is considered stale (default: 0)
cacheTimeAlias for gcTime
gcTimeTime in ms before unused cache entries are garbage collected
placeholderDataData to show while loading. Use keepPreviousData to retain previous results.
forceSkip cache and always fetch (call-time only)
enabled(state) => boolean — skip query when condition is false
dependsOn(state) => any — block query until dependency is truthy/resolved
refetchIntervalnumber | false | (data, error?) => number | false — auto-poll interval
suspenseboolean — enable React Suspense integration

queryFn context

queryFn receives (arg, context) where context.state is a readonly snapshot of the current query state:

posts: query<Post[]>({
  initialData: [],
  queryFn: (_, { state }) => {
    // state.data, state.isLoading, etc.
    return api.post.findAll()
  },
})

query.infinite()

For infinite scroll / pagination. Use Query.Infinite<T> as the type.

import { Query } from 'comwit'

// types.ts
export type PostState = {
  trending: Query.Infinite<Post[]>
}
// model.ts
import { query } from 'comwit'

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

// types.ts
export type ChatState = {
  messages: Query.Realtime<Message[]>
}
// model.ts
import { query } from 'comwit'

export const chat = model<ChatState>({
  messages: query.realtime<Message[]>({
    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[]>({
    initialData: [],
    queryFn: () => api.comment.findAll(post.detail.data.id),
    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
  • Otherwise (truthy) — allows the query

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.


Suspense

Integrate with React <Suspense> boundaries. The component suspends on initial load and renders once data is available.

import { query } from 'comwit'
import type { Query } from 'comwit'

// types.ts
type PostState = {
  posts: Query.Suspense<Post[]>
}

// model.ts
const post = model<PostState>({
  posts: query<Post[]>({
    initialData: [],
    queryFn: () => api.post.findAll(),
    suspense: true,
  }),
})
// component.tsx
function PostList() {
  const posts = usePost((s) => s.posts.data)
  // data is guaranteed non-null here — Suspense handles the loading state
  return posts.map((p) => <div key={p.id}>{p.title}</div>)
}

function Page() {
  return (
    <Suspense fallback={<Spinner />}>
      <PostList />
    </Suspense>
  )
}

Behavior

  • Initial .query() call suspends (throws a Promise for React to catch)
  • refetch() does not suspend — uses background fetching with isFetching
  • Errors are thrown to the nearest ErrorBoundary
  • Use Query.Suspense<T> or Query.SuspenseInfinite<T> for correct typing (data is NonNullable)