Reactive queries
Subscribe to a query, not a channel — a result array that stays consistent as data changes, with zero client reducer boilerplate.
A reactive query (a.k.a. live query) subscribes to a query — a collection plus filter / sort / limit — and keeps its result array consistent as the underlying data changes. You get the initial page, then a fresh array on every relevant change. No manual event wiring, no stale data, no hand-written “on created insert, on deleted splice” reducer.
It builds on realtime: backlex already streams permission-filtered
per-row events over items:<slug> SSE. A reactive query layers query-level
maintenance on top of that stream — entirely in the client, so the server stays
a stateless event publisher and live queries ride on whatever realtime
transport the deployment provides (in-process SSE on Bun / Node / Deno,
Durable Objects on Cloudflare, or Redis Streams on stateless serverless), with
no per-subscription server state — so they work on any of backlex’s supported
deploy targets.
SDK
import { createClient } from "backlex";const backlex = createClient({ url: "https://api.example.com" });
const stop = backlex.liveQuery( "todos", { filter: { done: { _eq: false } }, sort: "-created_at", limit: 50 }, (rows) => render(rows), // called with the initial page, then on every change (err) => console.error(err), // optional);
// laterstop();liveQuery(slug, opts, onResult, onError?) takes the same opts as
list() (filter, sort, fields, limit, expand, q,
locale, status). onResult receives a new array each time (safe to set
straight into state). It returns an unsubscribe function.
React
import { useLiveQuery } from "backlex/react";
function Todos({ backlex }) { const { data, loading, error } = useLiveQuery(backlex, "todos", { filter: { done: { _eq: false } }, sort: "-created_at", limit: 50, }); if (loading) return <Spinner />; return <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;}React is an optional peer dependency — importing backlex/react is the only
thing that pulls it in. The hook re-subscribes when slug or (deep-equal)
opts change and unsubscribes on unmount.
How it stays consistent
For a simple query — top-level field filter + sort, no relations or search — the engine maintains the array incrementally in JS:
- created / updated matching the filter → inserted (or moved) to its sorted position.
- created / updated that no longer matches → removed.
- deleted → removed.
Most changes need zero extra round-trips. The client-side matcher mirrors the permission DSL operators, so “matches the filter” means the same thing it does server-side.
Server-side narrowing + transitions
When the filter has no nested (a.b) keys, the SDK also forwards it to the
realtime subscription (?filter=). The server then delivers only the events
whose row matches — so the stream carries less, not the whole collection — and
annotates each with a membership transition (enter / leave / update) it
computed by evaluating the filter against the row before AND after the write.
The decisive one is leave: an update that pushes a row out of the result
set is still delivered (the after-row no longer matches) so the client can drop
it. The client trusts the transition when present, which is also what lets the
server’s resolution of $user.* / $now drive membership — the client can’t
evaluate those in JS. (The server validates the forwarded filter: it must
reference readable, non-relation fields, or the subscription is rejected.)
Limit windows
With a limit, the incremental apply is optimistic (instant UI) and a
debounced reconcile refetch pulls the exact window — because when a row
leaves a full window, the next off-window row should slide in, and the client
doesn’t have it cached. You get snappy feedback plus eventual exactness.
Refetch fallback
Anything the engine can’t safely maintain in JS transparently falls back to a debounced refetch (always correct, just less incremental):
expand(the related object is computed server-side)qfull-text search (relevance is server-ranked)- nested
a.bpaths infilterorsort $now/$user.*values in the filter (resolved server-side)
Bursts of events (e.g. a batch write) are coalesced into a single refetch.
Permission & tenant scope
A reactive query inherits the realtime feed’s guarantees: you only ever receive
events for rows you can read, already field-projected to your permission
allow-list. The initial list() and every refetch apply the same read gate. A
reactive query can never surface a row the caller couldn’t fetch directly.
In the admin
The admin’s collection item list is itself a reactive query: a Live badge appears while the realtime channel is connected, and the table refreshes as rows change — from another tab, another admin, the SDK, or a flow.
Not covered
Reactive queries are an SDK + realtime concern, so they have no GraphQL / MCP /
CLI surface (those are request/response). Aggregates aren’t reactive — use a
liveQuery over the rows and reduce client-side, or refetch the aggregate on a
liveQuery change. Server-side incremental view maintenance (a true materialized
reactive cache) is a possible future direction; today the maintenance is
client-side, which is what keeps it stateless and runtime-portable.