跳到主要内容
For AI agents: a documentation index is available at /llms.txt

Queries

Queries provide built-in async data fetching with caching, directly in your model definition. They handle cache identity, staleness, cancellation, and garbage collection out of the box.

Defining Queries

Add a queries field to your model. Each query entry is a function. Configure per-query options, such as staleTime, in the optional second argument to defineModel().

import { defineModel } from 'doura'

const userModel = defineModel(
{
name: 'user',
state: {
currentUser: null as User | null,
},
queries: {
fetchAll: async function (ctx) {
const res = await fetch('/api/users', { signal: ctx.signal })
return (await res.json()) as User[]
},

fetchById: async function (ctx, id: string) {
const res = await fetch(`/api/users/${id}`, { signal: ctx.signal })
return res.json()
},
},
},
({ model }) => {
model.setQueryOptions('fetchById', {
staleTime: 30_000,
onData({ api, data }) {
// Use onData to sync fetched data into model state
api.currentUser = data
},
})
}
)

Cache Identity

Each query entry maintains a cache keyed by its arguments. No custom key function is needed: the args tuple determines cache identity automatically.

// These are different cache entries:
instance.fetchById('user-1')
instance.fetchById('user-2')

// Same args tuple values = same cache entry (deduped):
instance.fetchById('user-1') // returns cached or dedupes inflight

QueryCtx and Cancellation

Every query function receives a QueryCtx as its first argument, which provides an AbortSignal. The signal fires when cancel() is called on the query handle.

queries: {
search: async function (ctx, term: string) {
const res = await fetch(`/api/search?q=${term}`, {
signal: ctx.signal,
})
return res.json()
},
}
警告

Query functions do NOT have this bound to the model — this is undefined. To update model state when data arrives, use the onData query option (see Configuration below).

Using Queries in Actions

Inside actions, direct query fetch functions are available as this.queryName. Use this.$queries.queryName for cache control:

actions: {
async loadUser(id: string) {
const user = await this.fetchById(id)
this.currentUser = user
},
refreshAll() {
this.$queries.fetchAll.invalidate() // mark stale, next observer will refetch
},
cancelPending() {
this.$queries.fetchById.cancel() // cancel all inflight fetchById requests
},
}

QueryHandle Methods

Each query handle is available at $queries.queryName and provides these methods:

MethodDescription
fetch(...args)Fetch and return data
prefetch(...args)Fetch to warm cache (Promise<void>)
getData(...args)Read cached data without fetching
getState(...args)Read raw cache entry
isFetching(...args)Check if currently fetching
isStale(...args)Check if data is stale
cancel(...args?)Cancel inflight request(s)
invalidate(...args?)Mark entry/entries stale
reset(...args?)Clear entry/entries entirely
setData(...args, data)Write data into the cache manually

Methods that accept optional args operate on a specific cache slot when args are provided, or on all slots when called with no arguments.

Configuration

Configure per-query options via model.setQueryOptions(name, options) in the defineModel setup function.

Available Options

OptionTypeDescription
staleTimenumberHow long data is fresh (ms). Default: 0 (always stale)
onData(ctx: OnDataCtx) => voidCallback when data arrives (fetch or setData)

Per-entry staleTime

Set staleTime with model.setQueryOptions() to control how long data is considered fresh for a specific query.

const userModel = defineModel(
{
name: 'user',
state: {},
queries: {
fetchUser: async function (ctx, id: string) {
/* ... */
},
},
},
({ model }) => {
model.setQueryOptions('fetchUser', { staleTime: 60_000 })
}
)

onData — Sync fetched data into state

The onData callback runs whenever new data arrives for a query — both from a fetch() completing and from manual setData() calls. It executes in an action context, so you can update state and call actions.

const userModel = defineModel(
{
name: 'user',
state: { currentUser: null as User | null },
queries: {
fetchUser: async function (ctx, id: string) {
const res = await fetch(`/api/users/${id}`, { signal: ctx.signal })
return res.json()
},
},
},
({ model }) => {
model.setQueryOptions('fetchUser', {
staleTime: 30_000,
onData({ api, args, data }) {
// api = model's internal proxy (same as `this` in actions)
// args = the args tuple passed to fetch (e.g. [id])
// data = the resolved data from the query
api.currentUser = data
},
})
}
)

OnDataCtx provides:

FieldTypeDescription
apiModelThe model proxy — update state, call actions
argsTArgsThe args tuple for this cache entry
dataTDataThe new data (from fetch result or setData)
提示

Use onData instead of writing state inside the query function itself when you want state updates to also fire for manual setData() calls. This keeps cache writes and state updates in sync regardless of how data enters the system.

Global configuration

Set default query behavior for the entire store:

import { doura } from 'doura'

const store = doura({
query: {
staleTime: 10_000, // default: 0 (always stale)
gcTime: 300_000, // default: 5 minutes (garbage collect unused entries)
},
})

Priority: hook override > per-entry staleTime > global staleTime.

Bulk Operations

Model instances provide bulk operations that affect all queries:

const instance = store.getModel(userModel)

instance.$invalidateQueries() // mark all entries stale
instance.$cancelQueries() // cancel all inflight requests
instance.$resetQueries() // clear all cache entries

React Integration

All hooks below (useQuery, useInfiniteQuery, useAction) require a <DouraRoot> ancestor in the component tree (or a custom container's <Provider>).

useQuery

Subscribe to a query's cache and auto-fetch when data is stale:

import { useQuery } from 'react-doura'

function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error, refetch } = useQuery(
userModel.fetchById,
[userId],
{ staleTime: 60_000, enabled: !!userId }
)

if (isLoading) return <div>Loading...</div>
if (error) return <div>Error!</div>
return (
<div>
{data.name} <button onClick={refetch}>Refresh</button>
</div>
)
}

No-arg queries pass the ref/fetch/handle and options directly:

const result = useQuery(userModel.fetchAll, { staleTime: 60_000 })

useInfiniteQuery

For paginated data that accumulates pages:

import { useInfiniteQuery } from 'react-doura'

function PostList() {
const { data, hasNextPage, fetchNextPage, isFetchingNextPage } =
useInfiniteQuery(postsModel.fetchPage, {
initialArgs: [1] as [number],
getNextArgs: (lastPage, allPages) =>
lastPage.hasMore ? ([allPages.length + 1] as [number]) : undefined,
})

return (
<div>
{data?.pages.flatMap((page) =>
page.items.map((item) => <div key={item.id}>{item.title}</div>)
)}
{hasNextPage && (
<button onClick={fetchNextPage} disabled={isFetchingNextPage}>
Load more
</button>
)}
</div>
)
}

useAction

Track action lifecycle (loading, success, error) in your component:

import { useAction } from 'react-doura'

function SaveButton() {
const { run, isPending } = useAction(formModel.save)

return (
<button onClick={() => run()} disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
)
}

See the API reference for full type details.