# Reactive Apps (/docs/client/reactive-apps)

---
title: Reactive Apps
description: Live queries, live counts and channels cost different things. Pick one per piece of state, then keep the one you picked small.
kind: guide
package: questpie
---

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 `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](/docs/client/realtime/errors) for the rejection error, and
[Tuning](/docs/infrastructure/realtime/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.

<Callout
	type="warn"
	title="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.
</Callout>

## 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.

```tsx
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.

```tsx
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](/docs/client/reactive-apps/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.

```tsx
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.

```ts
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](/docs/client/reactive-apps/high-frequency-events) |
| Server limits, and who shares one refresh        | [Subscription cost](/docs/client/reactive-apps/subscription-cost)         |
| `live()`, `liveIter()`, topic rejection, locales | [Realtime](/docs/client/realtime)                                         |
| Channel definitions, publishing, presence        | [Channels](/docs/client/channels)                                         |
| Query keys, invalidation, mutation options       | [TanStack Query](/docs/client/tanstack-query)                             |
| Two people editing one field                     | [Collaborative documents](/docs/client/collaborative-documents)           |
| Transport selection and server wiring            | [Realtime adapter](/docs/infrastructure/realtime)                         |

## Next

**[High-frequency events](/docs/client/reactive-apps/high-frequency-events)** is
what to do when a channel fires faster than a component can render.
