Seed Protocol
@seedprotocol/react

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[] | undefined

The real parameter type is any; the signature above shows what the hook accepts.

Parameters

NameTypeDefaultDescription
queryfunction, Drizzle query, null or undefinedrequiredThe 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 are snake_case (local_id, created_at).
  • A Drizzle select built from BaseDb.getAppDb() and the table objects exported by @seedprotocol/sdk (seeds, metadata, versions and others), with operators such as eq and like from @seedprotocol/sdk/drizzle. That entry re-exports the SDK's own copy of drizzle-orm, so you don't install Drizzle yourself. Rows come back with the field names of your select, usually camelCase.

Returns

T[] | undefined: the rows, as a bare array (not an object).

  • undefined until the first result arrives, including while the client isn't ready and while query is null or undefined from 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.
  • undefined again when query changes, until the new query's first result arrives, when query becomes null or undefined, 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 seeds fail. A query that SQLite can answer from an index alone, such as sql`SELECT local_id FROM seeds` or sql`SELECT count(*) FROM seeds WHERE type = 'post'`, fails with The passed SQL does not read any tables.. Read a column no index holds, such as count(created_at) in place of count(*). The seeds indexes cover local_id, uid and type.
  • Errors aren't returned. If the query fails, the hook keeps its last rows, or undefined if it never got any, so a query with a typo looks like one that's still loading. The SDK logs the error with debug under seedSdk:browser:db:Db, which prints nothing unless you enable it; the console shows only SQLite's sqlite3_step() warning. A query SQLite can't prepare, such as one naming a missing table, fails with argument to tables_used() is not a valid SQL statement rather 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. Use useLiveQueryState to get the error.
  • Browser only for reactivity. Re-runs on writes rely on the browser database (SQLocal). On Node, BaseDb.liveQuery polls 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.

FieldTypeDescription
dataT[] | undefinedThe rows, exactly as useLiveQuery returns them
errorError | nullThe 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>
}

On this page