Reactive Apps
Live queries, live counts and channels cost different things. Pick one per piece of state, then keep the one you picked small.
Not every screen should be live. A live query holds a subscription open and re-runs a real database query whenever a matching row changes. That is worth paying for a chat window. It is not worth paying for a settings page.
Pick one mechanism per piece of state
| The state | Reach for | Why |
|---|---|---|
| Rows that change while the user watches | find(…, { realtime: true }) | The server re-runs your query and pushes the new result. |
| One derived number, such as a cart badge | count(…, { realtime: true }) | A number crosses the wire instead of a row list. |
| A global someone else may edit | get(…, { realtime: true }) | Same connection, one row. |
| Ordered transient events, such as typing or progress | A channel | Each payload is validated and delivered in order. |
| Anything that changes only when this user acts | A normal query and invalidation | Nothing stays subscribed and no server query stays open. |
findOne() has no live form. Subscribe with find() and a where that matches
the one row you want.
Do not publish a channel event to announce an ordinary create, update or delete. With realtime on, each QUESTPIE mutation already writes an outbox row inside its own transaction. Live queries wake from that row.
Durable history belongs in a collection. A channel keeps a bounded replay window, not an archive.
A live query re-sends its whole result
There are no row patches on the wire. The server re-runs find, count or
get under the subscriber's own access rules and sends the fresh result. One
row changing in a list of eighty means eighty rows come back.
So the cost of a live query is the cost of its query, paid again on every matching change. Narrow it before you subscribe.
- Filter to the exact tenant, cart, room or user with
where. - Set a
limityou actually render. Leave it unset and the server pins it to 100 rows. - Ask for only the relations the visible component reads.
- Use
countfor a badge. Never subscribe to a list to display its length.
A limit above 100 is rejected outright, not clamped. See
Errors and limits for the rejection error, and
Tuning for raising the cap.
What the subscription actually carries
The builders accept every find() option. The subscription carries six of them.
| Carried | Accepted, then dropped |
|---|---|
where, with, limit, offset, orderBy, locale | columns, extras, search, groupBy, includeDeleted, stage, localeFallback |
A dropped option changes the TanStack query key but not the subscription. Two keys can then share one topic, and both receive the snapshot the dropped option was meant to change. Run those reads as a normal one-shot query instead.
A live count() is narrower again. It carries only its where, so
includeDeleted: true type-checks and the count still skips soft-deleted rows.
The second argument only applies when `realtime` is true
find(options, config) merges the rest of config into the query options
only if config.realtime is set. Without the flag the builder ignores it,
silently. Spread the builder result when you want options either way.
One connection, many query keys
On the default transport the client multiplexes every topic over one
POST /realtime connection. That is a shared connection, not shared state.
Each topic gets an id derived from its whole normalized shape. The resource, the
operation, where, with, limit, offset, orderBy and locale all feed
it. A frame reaches only the callbacks registered for its own topic id.
TanStack Query then stores each query under its own key.
function CartBadge({ cartId }: { cartId: string }) {
const { data: items = 0 } = useQuery(
q.collections.cartItems.count({ where: { cartId } }, { realtime: true }),
);
return <span aria-label={`${items} items in cart`}>{items}</span>;
}
function OpenOrders({ tenantId }: { tenantId: string }) {
const { data } = useQuery(
q.collections.orders.find(
{ where: { tenantId, status: "open" }, limit: 20 },
{ realtime: true },
),
);
return <OrderList orders={data?.docs ?? []} />;
}A new order updates the orders topic and the orders query key. It does not touch
the cart, so CartBadge does not re-render.
Ordinary React composition still applies. Read both values in one parent and that parent renders when either changes. Keep unrelated live reads in separate leaf components.
Render less than you subscribe to
Spread the builder result to project a live snapshot down to what the component draws.
const liveOrders = q.collections.orders.find(
{ where: { status: "overdue" }, limit: 20 },
{ realtime: true },
);
const { data: firstOrderId } = useQuery({
...liveOrders,
select: (snapshot) => snapshot.docs[0]?.id,
});The spread is the form that lets select return a narrower type. A select
passed inside the second argument must return the full result type.
Define the selector outside the component or memoize it, so it stays stable
across renders. Use select to cut React work, not to hide an oversized server
query. Narrow the query first.
Bound what you keep from a channel
q.channels.<name>.subscription(params) starts at [] and appends every
message it receives. The array grows for as long as the query is cached. That is
fine for a short-lived, low-rate stream and wrong for cursors or typing.
For anything noisy, subscribe directly and keep an explicit window. See High-frequency events.
Clean up
TanStack Query passes an abort signal into the stream. Cancelling the query aborts it, so the query lifecycle handles teardown for you. The direct client APIs do not. Pass your own signal.
useEffect(() => {
const controller = new AbortController();
client.collections.orders.live(
{ where: { status: "open" }, limit: 20 },
onSnapshot,
{ signal: controller.signal },
);
return () => controller.abort();
}, []);Four counters tell you whether teardown works.
client.realtime.topicCount;
client.realtime.subscriberCount;
client.channels.channelCount;
client.channels.subscriberCount;Navigate around the app and come back. A count that keeps climbing means a missing cleanup. It can also mean subscription parameters that change identity on every render.
Where each topic lives
| Topic | Page |
|---|---|
| Noisy channels, bounded windows, gaps | High-frequency events |
| Server limits, and who shares one refresh | Subscription cost |
live(), liveIter(), topic rejection, locales | Realtime |
| Channel definitions, publishing, presence | Channels |
| Query keys, invalidation, mutation options | TanStack Query |
| Two people editing one field | Collaborative documents |
| Transport selection and server wiring | Realtime adapter |
Next
High-frequency events is what to do when a channel fires faster than a component can render.