Reactive queries
Declare a query once. It stays correct forever, across components, tabs, devices and backend jobs.
That list is live. When anyone creates, edits, or deletes a user, in this component, in another
tab, on another device, or from a backend job, the component re-renders with the new
data. You never write a refetch, an invalidation rule, or a subscription teardown.
Building a query
db.query(table) returns a fluent builder. Nothing runs until you .build(), and .build()
returns a plan, not results.
| Method | Purpose |
|---|---|
.where(conditions) | Filter rows. See below. |
.select(...fields) | Restrict the projection. Defaults to every column. |
.related(field) | Replace a record id with the full related record. |
.orderBy(field, dir) | Sort. Chain calls for multi-key ordering. |
.limit(n) / .offset(n) | Window the result. See Pagination. |
.one() | Return a single record instead of an array. |
.build() | Freeze the plan so useQuery can register it. |
Filtering
A plain value means equality. For anything else, pass a comparison descriptor: _op is the
operator, _val the value. A top-level _or compiles to a parenthesised OR group.
Pulling in related records
.related() follows a record<...> field and returns the whole record instead of an id. One call
covers 1:1, 1:N and graph edges. Sp00ky reads your schema.surql to work out the traversal.
You never write graph paths like ->liked->post or join conditions. The relation table you
declared in the schema is enough for Sp00ky to generate the right query.
The return type changes with the call: without .related() the field is a RecordId, with it the
field is the related object. Accessing a relation you didn’t fetch is a compile error.
Subscribing with useQuery
useQuery(db, factory) registers the plan and keeps the result current for the lifetime of the
component.
| Accessor | Returns |
|---|---|
data() | Current rows, or undefined before the first result. |
error() | The last error, if the query failed. |
isLoading() | true until there is anything to show. |
isFetching() | true while a fetch is in flight, including refreshes. |
isSettled() | true once the query has a server-confirmed result. Use this, not isLoading, to decide “the list really is empty”. |
Two options are worth knowing:
| Option | Effect |
|---|---|
enabled: () => boolean | Skip registration until the accessor returns true. |
deregisterOnCleanup: true | Tear the synced view down on unmount instead of keeping it warm. See Pagination. |
If your app is wrapped in <Sp00kyProvider>, you can drop the first argument and call
useQuery(() => …). The client comes from context.
Because the factory is a function, anything reactive you read inside it re-registers the query when it changes. That’s how filters, sort order and pagination all work. You change a signal, and the live window follows.
Under the hood useQuery calls subscribe(queryHash, callback, { immediate: true }). The
immediate flag fires the callback synchronously with whatever is already in the local store, so
the first paint has data even offline. UPDATE events are debounced by streamDebounceTime
(default 50 ms, see Client config); CREATE and DELETE fire
immediately. Subscriptions tear down on unmount.
One-off reads
Sometimes you want a value rather than a subscription, inside an event handler say. Call .run() on the
built query and await it.
For SurrealQL the builder can’t express, db.queryRaw(sql, params, ttl) is the escape hatch. Raw
queries are not reactive.