# Live queries

> Keep any query fresh over Realtime, without sending row data over the socket.

Source: https://bettersupabase.com/docs/frontend/live-queries

A live query refetches when a table it reads changes. The database sends
only "`customers` changed"; the client refetches through PostgREST, so RLS
decides what each user sees, and includes and filters stay correct.

## Setup [#setup]

1. List the tables in the config and regenerate:

   ```ts title="better-supabase.config.ts"
   export default defineConfig({
     realtime: { tables: ["customers", "notes"] },
   });
   ```

2. Add the SQL module. It installs a statement-level trigger per table
   (one broadcast per statement, not per row) and a `realtime.messages`
   policy for the topics:

   ```bash
   better-supabase gen
   better-supabase sql add realtime-tables
   ```

With the [tenant plugin](/docs/plugins/tenant), tables broadcast on a
per-tenant topic (`bs:t:public.customers:<organization>`), and the policy only lets a
user join the tenant in their `tenant_id` claim. A table without the tenant
column fails to install, so a tenant table never broadcasts to everyone by
mistake. List tables that are global on purpose in `realtime.global`; they
use one topic that every signed-in user receives:

```ts title="better-supabase.config.ts"
realtime: { tables: ["customers", "plans"], global: ["plans"] },
```

A table whose rows each belong to one user, such as notifications, can
broadcast per user instead: map it to its user column in `realtime.users`.
It uses the topic `bs:t:public.notifications:u:<user id>`, which only that
user receives, so a change to one user's rows doesn't make the rest of the
tenant refetch. The React hooks pass the signed-in user; `liveQuery` and
`liveCount` take it as `user`.

```ts title="better-supabase.config.ts"
realtime: { tables: ["notifications"], users: { notifications: "user_id" } },
```

## React [#react]

```tsx
const spec = betterSupabase.spec.customers.findMany({
  include: { notes: true },
  limit: 50,
});

function Customers() {
  const { data } = useQuery(bs.queries.$spec(spec));
  useLiveQuery(spec); // refetches on changes to customers or notes
  return <List rows={data} />;
}
```

`useLiveQuery` needs `<BetterSupabaseProvider queryClient={queryClient}>`.
The tenant defaults to the `tenant_id` claim (`claims.tenant` in the config); pass `{ tenant }` to override it.
Changes are debounced (100 ms by default, `debounceMs`), so a bulk update
causes one refetch. It returns the channel status.

## Counts [#counts]

Badges (unread messages, open tasks) only need a number. `useLiveCount`
runs a `count` spec, which is a HEAD request with no rows, and reruns only
that after each debounced change. It needs no `QueryClient`:

```tsx
const unread = betterSupabase.spec.notifications.count({
  where: { readAt: null },
});

function UnreadBadge() {
  const { count, status, error } = useLiveCount(unread);
  return <Link href="/inbox">Inbox {count ?? "–"}</Link>;
}
```

To render the number on first paint, count on the server and pass the
seed. The client then skips its first request:

```tsx
// Server Component, inside <Suspense>
const seed = await bs.liveCount(unread); // { spec, count }
return <UnreadSummary seed={seed} />;

// Client Component
const { count } = useLiveCount(seed);
```

`bs.liveCount` uses `bs.context()`; inside `'use cache: private'`,
pass the `db` from `bs.cached()` as the second argument. For a tenant from
the route, pass the `db` from `bs.context({ tenant })` or
`bs.cached({ tenant })`, and give the client hook the same
`{ tenant }`. A failed count
gives `count: null`, and the client fetches it instead. Don't seed from a
cache entry that can outlive changes made by other users: changes made
before the client joins the channel aren't broadcast to it.

A seed costs a database call in the render that makes it. For a badge in
the layout, which is part of every page, skip the seed and let the
browser count. For in-app notifications, the
[notifications block](/docs/blocks/notifications) has its own tables,
counts and private topic; the [Next.js example](/docs/examples) builds its
header badge on it with `useNotifications`.

## Without React [#without-react]

```ts
import { liveQuery } from 'better-supabase/realtime';

const live = liveQuery(betterSupabase, supabase, spec, {
  tenant: organizationId,
  onChange: (tables) => queryClient.invalidateQueries(...),
});
await live.ready;
live.unwatched; // tables the query reads that don't broadcast
await live.unsubscribe();
```

Pass a list of table keys instead of a spec to watch tables directly.
Channels are shared: ten live queries on `customers` open one channel.

`liveCount` is the same for a count spec, with the result:

```ts
import { liveCount } from "better-supabase/realtime";

using live = liveCount(betterSupabase, supabase, db, unread, {
  tenant: organizationId,
  onCount: (count) => render(count),
  onError: (error) => report(error), // the previous count stays valid
  immediate: true, // pass false when you already have a count
});
```

Out-of-order responses are dropped, so a slow request can't overwrite a
newer count.

## Reconnects [#reconnects]

Broadcast is best effort: changes sent while a client is disconnected are
lost. When a channel rejoins after `CHANNEL_ERROR`, `TIMED_OUT` or
`CLOSED`, every live query and live count on it refetches once, as if its
tables had changed.

## Doctor [#doctor]

* **BS305** flags a table in `realtime.tables` without the trigger.
* **BS306** flags tables in the `supabase_realtime` publication whose replica
  identity can't identify deleted rows.

## Limits [#limits]

A change to a table outside `realtime.tables` doesn't refresh the query;
`unwatched` lists those tables. A refetch costs one request per changed
query, which suits dashboards and lists better than high-frequency streams.
For cursors, presence or chat, use [Realtime topics](/docs/platform/realtime).