Skip to main content

Reactive queries

A reactive query is a server-side query() handler that automatically re-runs whenever the data it reads changes. The client subscribes once; the server pushes a new result every time a mutation touches anything in the handler’s dependency set. This is the “live joined query” shape Convex made famous. Pylon ships the same model with native auth, multi-tenant policies, and self-host.

Writing a reactive query

Any query() handler is eligible — no opt-in flag, no special decorator. The reactivity is unlocked at the call site by using db.useReactiveQuery instead of a one-shot useFn / fetch.
// functions/getFeed.ts
import { query } from "@pylonsync/functions";

export default query(async (ctx, args: { userId: string }) => {
  const posts = await ctx.db.list("Post");
  return Promise.all(
    posts
      .filter((p) => p.authorId !== args.userId) // exclude self
      .slice(0, 50)
      .map(async (p) => ({
        ...p,
        author: await ctx.db.get("User", p.authorId),
      })),
  );
});
The runtime watches every ctx.db.* call this handler makes and records the set of entities + row ids it touched. In the example above the dep set is { Post: *, User: { authorId1, authorId2, ... } }.

Subscribing from React

import { db } from "@pylonsync/react";

function Feed({ userId }: { userId: string }) {
  const { data: feed, loading, error } = db.useReactiveQuery<FeedItem[]>(
    "getFeed",
    { userId },
  );
  if (loading) return <Spinner />;
  if (error) return <ErrorBanner error={error} />;
  return feed.map((item) => <PostCard key={item.id} item={item} />);
}
On mount: the hook sends reactive-subscribe over the WebSocket; the server runs getFeed, captures deps, registers the sub, and pushes the initial result. After that, every server-side mutation touching Post or a User row in the captured set triggers a re-run. The server hashes the new result and pushes only when it differs from the last value sent — so a mutation that doesn’t change the rendered output costs one re-run on the server but no client re-render. On unmount: the hook sends reactive-unsubscribe. The server drops the subscription and stops re-running.

Auth context across re-runs

The handler runs under the subscriber’s auth context on every re-run — never the mutating user’s. A policy like auth.userId == row.ownerId applied at first run applies on every subsequent re-run. This is the only correct semantic. If re-runs used the mutating user’s auth, a Stripe webhook (running as an elevated admin) would re-evaluate getFeed with admin privileges and push the unfiltered result to a logged-in user — a silent read-policy bypass.

Dependency tracking granularity

The runtime tracks two levels:
  • Entity-level: any read of ctx.db.list("Post") or ctx.db.query("Post", {...}) marks the dep as “entity Post, any row”. Mutations to any Post row dirty the sub.
  • Row-level: ctx.db.get("User", "u_123") marks the dep as “entity User, row u_123”. Mutations to other User rows do NOT dirty the sub.
The dep set is precise for get + lookup, coarse for list + query + search. Handlers that mix both get the union — list reads contribute entity-level deps; targeted reads contribute row-level deps on top. Row-level deps are capped per subscription (default 256). Beyond the cap, the sub falls back to entity-level matching. Prevents runaway queries from bloating the registry.

Coalescing + re-run cadence

Multiple change events touching the same sub within one tick coalesce to a single re-run. The re-runner thread drains the dirty set on a notify-driven cadence (no fixed interval — wakes immediately when work arrives). Bursts of writes don’t cause per-event re-runs.

When to use reactive queries vs entity queries

Use caseReactive queryEntity query (useQuery)
Server-side joins (entity + related rows)
Aggregations (count, sum, group-by)
Computed fields derived from multiple rows
Simple where filter on one entity
Fully cached IndexedDB replica
Offline-first writesServer roundtrip requiredOptimistic UI works offline
Use reactive queries when the value rendered on screen is the output of a function that touches multiple entities or computes something the client couldn’t easily derive from cached rows. Use entity queries (db.useQuery) when the screen is a list of rows from one entity and the client can filter / sort / paginate locally for free.

Cross-machine support

Reactive queries work on multi-machine deployments — the runtime forwards change events into the registry from both the local mutation path AND the cluster bus subscriber (see horizontal scaling). A mutation on machine A dirties subs on machine B; B’s re-runner runs the handler and pushes the result to its connected client.

Limits

  • One handler call per re-run. There’s no incremental dataflow (Materialize / differential dataflow). For very expensive handlers, the re-run cost is the full handler cost — design with pagination / limit to keep handlers fast.
  • The hash check skips client pushes when the result hashes identical. It doesn’t skip the re-run itself. A handler that reads 1k rows but produces a small derived value runs the full query on every dirty event.
  • Reactive subs require the TS function runtime (Bun process). When the runtime isn’t available (no functions/ directory), the hook receives a REACTIVE_UNAVAILABLE error push and the consumer can fall back to a one-shot fetch.