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:
| Method | Description |
|---|---|
.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
| 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
Pass options at the query definition or globally via ComwitProvider:
| Option | Description |
|---|---|
staleTime | Time in ms before cached data is considered stale (default: 0) |
cacheTime | Alias for gcTime |
gcTime | Time in ms before unused cache entries are garbage collected |
placeholderData | Data to show while loading. Use keepPreviousData to retain previous results. |
force | Skip 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 |
refetchInterval | number | false | (data, error?) => number | false — auto-poll interval |
suspense | boolean — 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
| 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 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:
| 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[]>({
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 untilisSuccessistrue - If the returned value is
nullorundefined— 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 withisFetching- Errors are thrown to the nearest
ErrorBoundary - Use
Query.Suspense<T>orQuery.SuspenseInfinite<T>for correct typing (dataisNonNullable)