useLiveQuery
Run a reactive SQL or Drizzle query against the app's local database and re-render whenever its rows change.
useLiveQuery subscribes a component to a query on the app's local SQLite database. Whenever a write touches a table the query reads, it runs again, and the component re-renders if the rows changed. It's the escape hatch for filters and sorts that the item hooks don't offer. Seed's own list hooks, such as useItems, are built on it.
import { useLiveQuery, useLiveQueryState } from '@seedprotocol/react'useLiveQuery
function useLiveQuery<T>(
query: ((sql: SqlTag) => Statement) | DrizzleQuery | null | undefined,
): T[] | undefinedThe real parameter type is any; the signature above shows what the hook accepts.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
query | function, Drizzle query, null or undefined | required | The query to run. Pass null or undefined to turn the hook off. |
It accepts two kinds of query:
- An SQL tag function:
(sql) => sql`SELECT … WHERE x = ${value}`. Interpolated values become bound parameters, not string concatenation. Rows come back with the database's column names, which aresnake_case(local_id,created_at). - A Drizzle select built from
BaseDb.getAppDb()and the table objects exported by@seedprotocol/sdk(seeds,metadata,versionsand others), with operators such aseqandlikefrom@seedprotocol/sdk/drizzle. That entry re-exports the SDK's own copy ofdrizzle-orm, so you don't install Drizzle yourself. Rows come back with the field names of yourselect, usuallycamelCase.
Returns
T[] | undefined: the rows, as a bare array (not an object).
undefineduntil the first result arrives, including while the client isn't ready and whilequeryisnullorundefinedfrom the start.- A new array each time the rows change. Results that are identical to the last ones (compared as JSON) don't re-render.
undefinedagain whenquerychanges, until the new query's first result arrives, whenquerybecomesnullorundefined, and when the client stops.
T is only a type hint. Nothing checks the rows against it, so describe the row shape your query actually returns.
Behavior
- Waits for the client. The hook doesn't run the query until the client is ready, so it's safe to render before init finishes.
- What triggers a re-run. Any write to a table the query reads, whether from the SDK or from your own SQL. A query that reads no table, or writes to a table it reads, fails on its first run.
- Index-only reads of
seedsfail. A query that SQLite can answer from an index alone, such assql`SELECT local_id FROM seeds`orsql`SELECT count(*) FROM seeds WHERE type = 'post'`, fails withThe passed SQL does not read any tables.. Read a column no index holds, such ascount(created_at)in place ofcount(*). Theseedsindexes coverlocal_id,uidandtype. - Errors aren't returned. If the query fails, the hook keeps its last rows, or
undefinedif it never got any, so a query with a typo looks like one that's still loading. The SDK logs the error withdebugunderseedSdk:browser:db:Db, which prints nothing unless you enable it; the console shows only SQLite'ssqlite3_step()warning. A query SQLite can't prepare, such as one naming a missing table, fails withargument to tables_used() is not a valid SQL statementrather than SQLite's own message. After a failure the hook subscribes again after 0.5, 1, 2, 5 and 10 seconds, then every 30 seconds, until a result arrives, so a query on a table that's created later starts returning rows. UseuseLiveQueryStateto get the error. - Browser only for reactivity. Re-runs on writes rely on the browser database (SQLocal). On Node,
BaseDb.liveQuerypolls instead and rejects tag functions; see Browser and Node.
Memoise SQL tag functions. A Drizzle builder is matched by its SQL and parameters, so building it inline during render is fine: the hook resubscribes only when the SQL or a parameter changes. A tag function is matched by reference. An inline arrow function is new on every render, so the hook resubscribes, gets a result, re-renders, and loops. Wrap it in useMemo or useCallback (or define it outside the component) and list only the values it uses as dependencies.
Example: SQL tag function
Drafts of the Post model, newest first. The seeds.type column holds the model's EAS name, easName(modelName) from @seedprotocol/sdk: Post is 'post', BlogPost is 'blog_post'. Tables lists the columns, and the older forms type can hold.
import { useMemo } from 'react'
import { easName } from '@seedprotocol/sdk'
import { useLiveQuery } from '@seedprotocol/react'
type SeedRow = { local_id: string; created_at: number }
function DraftCount() {
const query = useMemo(
() => (sql: any) =>
sql`SELECT local_id, created_at FROM seeds
WHERE type = ${easName('Post')} AND (uid IS NULL OR uid = '')
AND (_marked_for_deletion IS NULL OR _marked_for_deletion = 0)
ORDER BY created_at DESC`,
[],
)
const rows = useLiveQuery<SeedRow>(query)
if (rows === undefined) return <p>Loading…</p>
return <p>{rows.length} draft(s)</p>
}Example: Drizzle query
Posts whose stored title contains a search term. Build the query only once the database exists, and pass null until then.
import { useMemo } from 'react'
import { and, eq, like } from '@seedprotocol/sdk/drizzle'
import { BaseDb, metadata } from '@seedprotocol/sdk'
import { useGlobalServiceStatus, useLiveQuery } from '@seedprotocol/react'
type TitleRow = { seedLocalId: string | null; title: string | null }
function TitleSearch({ term }: { term: string }) {
const { status } = useGlobalServiceStatus()
const isReady = status === 'idle'
const query = useMemo(() => {
const db = isReady ? BaseDb.getAppDb() : null
if (!db || !term) return null
return db
.selectDistinct({ seedLocalId: metadata.seedLocalId, title: metadata.propertyValue })
.from(metadata)
.where(and(eq(metadata.propertyName, 'title'), like(metadata.propertyValue, `%${term}%`)))
}, [isReady, term])
const rows = useLiveQuery<TitleRow>(query)
return (
<ul>
{(rows ?? []).map((row) => (
<li key={`${row.seedLocalId}:${row.title}`}>{row.title}</li>
))}
</ul>
)
}metadata keeps one row per saved value, including older versions, so a query like this can match a title the post no longer has. Use it to find candidate IDs, then load each item with useItem to show its current values.
The table layout is internal to the SDK and can change between versions (see Tables). Prefer useItems and useItem where they're enough, and keep raw queries in one place so they're easy to update.
useLiveQueryState
useLiveQuery with its error.
function useLiveQueryState<T>(
query: ((sql: SqlTag) => Statement) | DrizzleQuery | null | undefined,
): { data: T[] | undefined; error: Error | null }It takes the same query and runs it the same way. useLiveQuery(query) returns this hook's data.
| Field | Type | Description |
|---|---|---|
data | T[] | undefined | The rows, exactly as useLiveQuery returns them |
error | Error | null | The error the last attempt failed with, whether setting up the live query or running it. The rows from before the error stay in data. Cleared by the next result and when query changes |
There's no isLoading: data === undefined && error === null means no result yet.
import { useLiveQueryState } from '@seedprotocol/react'
type ModelRow = { name: string }
const modelNames = (sql: any) => sql`SELECT name FROM models ORDER BY name`
function ModelNames() {
const { data, error } = useLiveQueryState<ModelRow>(modelNames)
if (error) return <p role="alert">Query failed: {error.message}</p>
if (data === undefined) return <p>Loading…</p>
return <p>{data.map((row) => row.name).join(', ')}</p>
}Related
- Querying with liveQuery: patterns for filters, joins and search
- Querying (BaseDb and liveQuery):
BaseDb, the exported tables, andliveQueryoutside React - Items:
useItemsanduseItem - Building a list view that updates in real time
- The reactivity model
Item properties
Hooks for reading and editing individual property values on an item: one field, all fields, debounced text input, and creating or removing property instances.
Publishing and EAS sync
React hooks for publishing an item (usePublishItem) and for watching the SDK's EAS sync runs (useEasSyncProcesses and related hooks).