QUESTPIE
Client

Reactive Apps

Live queries, live counts and channels cost different things. Pick one per piece of state, then keep the one you picked small.

View markdown

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 stateReach forWhy
Rows that change while the user watchesfind(…, { realtime: true })The server re-runs your query and pushes the new result.
One derived number, such as a cart badgecount(…, { realtime: true })A number crosses the wire instead of a row list.
A global someone else may editget(…, { realtime: true })Same connection, one row.
Ordered transient events, such as typing or progressA channelEach payload is validated and delivered in order.
Anything that changes only when this user actsA normal query and invalidationNothing 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 limit you actually render. Leave it unset and the server pins it to 100 rows.
  • Ask for only the relations the visible component reads.
  • Use count for 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.

CarriedAccepted, then dropped
where, with, limit, offset, orderBy, localecolumns, 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

TopicPage
Noisy channels, bounded windows, gapsHigh-frequency events
Server limits, and who shares one refreshSubscription cost
live(), liveIter(), topic rejection, localesRealtime
Channel definitions, publishing, presenceChannels
Query keys, invalidation, mutation optionsTanStack Query
Two people editing one fieldCollaborative documents
Transport selection and server wiringRealtime adapter

Next

High-frequency events is what to do when a channel fires faster than a component can render.

On this page