Skip to Content
RealtimeReact: useLiveQuery()

React Live Queries

@kalamdb/react turns a KalamDB live query into React rows and state. Start with useLiveQuery: the hook owns subscription setup and cleanup, while your component renders loading, empty, error, and data states.

Prepare the live-query example

Create the app.live_messages table from the TypeScript inbox setup, then install:

BASH
npm install @kalamdb/client @kalamdb/react @kalamdb/orm drizzle-orm

These examples target a React TypeScript app. In Next.js, put the provider and query components behind a 'use client' boundary. Pass the URL and your auth integration’s token getter from the application; never bundle an admin password.

One client per signed-in session

TSX
'use client'; import { useState } from 'react';import { Auth, createClient } from '@kalamdb/client';import { KalamProvider } from '@kalamdb/react';import { MessagesPane } from './MessagesPane'; type AppProps = {  url: string;  getAccessToken: () => Promise<string>;}; export function App({ url, getAccessToken }: AppProps) {  const [client] = useState(() => createClient({    url,    authProvider: async () => Auth.jwt(await getAccessToken()),    onDisconnect: (reason) => console.warn('Connection interrupted:', reason.message),    onConnectionError: (error) => console.error('Connection:', error.message),  }));   return (    <KalamProvider client={client}>      <MessagesPane key="main" roomId="main" />    </KalamProvider>  );}

The auth integration must return a current token, including after refresh. Keep the client stable across ordinary renders. When the signed-in account changes, unmount its query tree, disconnect its client, and create a client for the new session. KalamProvider supplies context; it does not own disconnecting your client.

Define the typed table

Use the schema generator in an existing project, or copy this definition for the example table. Declaring a TypeScript table does not create the server table.

TS
import { kTable } from '@kalamdb/orm';import { text } from 'drizzle-orm/pg-core'; export const messages = kTable.user('app.live_messages', {  id: text('id').primaryKey(),  room: text('room').notNull(),  body: text('body').notNull(),});

Render rows and handle writes

TSX
'use client'; import { useState } from 'react';import { useLiveQuery } from '@kalamdb/react';import { asc, eq } from 'drizzle-orm';import { messages } from './schema'; export function MessagesPane({ roomId }: { roomId: string }) {  const [draft, setDraft] = useState('');  const [actionError, setActionError] = useState<string>();  const { rows, state, insert, update, remove, clearError, refetch } = useLiveQuery({    table: messages,    where: (table) => eq(table.room, roomId),    orderBy: (table) => asc(table.id),    limit: 100,    deps: [roomId],  });   // Mutations set state.error AND reject. Catch every event-handler promise.  async function run(action: () => Promise<void>) {    setActionError(undefined);    clearError();    try { await action(); }    catch (error) {      setActionError(error instanceof Error ? error.message : String(error));    }  }   const error = actionError ?? state.error?.message;  const canWrite = state.status === 'live' && !state.loading;   return (    <section aria-label="Inbox">      {state.loading && <p role="status">Loading messages…</p>}      {error && <p role="alert">{error}</p>}      {state.status === 'error' && rows.length > 0 && (        <p>Showing the last received messages; they may be out of date.</p>      )}      <button        type="button"        disabled={state.loading}        onClick={() => { void run(refetch); }}      >        Reload messages      </button>      <button type="button" onClick={() => {        clearError();        setActionError(undefined);      }}>        Dismiss write error      </button>      {!state.loading && !error && state.status === 'live' && rows.length === 0 && (        <p>No messages yet. Send the first one.</p>      )}      <ul>        {rows.map((row) => (          <li key={row.id}>            {row.body}            <button              type="button"              disabled={!canWrite || state.updating.has(row.id) || state.deleting.has(row.id)}              onClick={() => { void run(() => update(messages, row.id).set({ body: `${row.body}!` })); }}            >              {state.updating.has(row.id) ? 'Saving…' : 'Add !'}            </button>            <button              type="button"              disabled={!canWrite || state.updating.has(row.id) || state.deleting.has(row.id)}              onClick={() => { void run(() => remove(messages, row.id)); }}            >              {state.deleting.has(row.id) ? 'Deleting…' : 'Delete'}            </button>          </li>        ))}      </ul>      <form onSubmit={(event) => {        event.preventDefault();        const body = draft.trim();        if (!body || state.inserting || !canWrite) return;        void run(async () => {          await insert(messages).values({ id: crypto.randomUUID(), room: roomId, body });          setDraft(''); // Keep the draft if the write fails.        });      }}>        <label>          Message          <input value={draft} onChange={(event) => setDraft(event.target.value)} disabled={state.inserting} />        </label>        <button disabled={!canWrite || state.inserting || !draft.trim()}>          {state.inserting ? 'Sending…' : 'Send'}        </button>      </form>    </section>  );}

Open the screen twice with the same user. Send a message in one, edit it in the other, then delete it. Both lists should follow the server changes. These helpers send writes; the live subscription supplies the resulting rows. The example does not add an optimistic local row.

Understand state, errors, and retries

ValueHow to use it
rowsCurrent materialized rows; replace the rendered list rather than merging whole snapshots yourself
state.loadingShow startup/reload progress; an empty array during loading is not an empty result
state.statusController state: idle, loading, live, reconnecting, error, or disposed
state.errorLatest mutation error, otherwise the live-controller error
state.insertingDisable a repeated send while a write is pending
state.updating / state.deletingSets of row keys for pending mutations
clearError()Clear the mutation error only; it does not repair a failed subscription
refetch()Restart the live subscription; call after fixing a table, query, or permission problem

A transient connection interruption is different from invalid SQL, missing tables, or denied access. Let the SDK manage transport reconnects. Fix permanent causes before using Reload. Do not blindly retry writes: a lost response does not prove the server rejected the write.

state.connected and state.status describe the live controller, not a complete transport-health monitor. The current hook does not forward every socket disconnect into its snapshot. Use the client’s connection callbacks for an application-wide offline indicator; do not treat state.connected as proof of freshness. Retained rows may remain visible while a reload is underway.

To test error handling, temporarily change the table name to a missing table. The screen should show an error, not “No messages yet.” Restore the schema and reload/remount. Test a rejected write with an identity that lacks write access: keep the input, show the error, and avoid an unhandled promise rejection. See authentication for credential refresh.

Filters, ordering, and cleanup

Put captured filter values in deps. Use Drizzle eq(table.room, roomId) instead of interpolating untrusted input into SQL. The hook cleans up its old subscription when its query changes and on unmount.

When switching rooms, render <MessagesPane key={roomId} roomId={roomId} /> to reset the component’s draft and old rows immediately. For an account switch, replace the session client too.

orderBy sorts locally and limit selects displayed rows; the matching row set remains retained so a deletion can reveal the next row. This is not a server-side memory bound. Narrow where for large datasets. The underlying subscription uses the supported SELECT ... FROM ... WHERE ... shape.

Do not disconnect the shared client in every list component. The application/session owner is responsible for it.

Raw SQL and render-prop alternatives

For fixed SQL without Drizzle models, use LiveQuery. Raw rows use typed cell accessors:

TSX
import { LiveQuery } from '@kalamdb/react'; export function SqlMessagesPane() {  return (    <LiveQuery query="SELECT id, body FROM app.live_messages WHERE room = 'main'" getKey="id">      {({ rows, state }) => (        <section>          {state.loading && <p>Loading…</p>}          {state.error && <p role="alert">{state.error.message}</p>}          {!state.loading && !state.error && rows.length === 0 && <p>No messages yet.</p>}          {rows.map((row) => <p key={row.id.asString()}>{row.body.asString()}</p>)}        </section>      )}    </LiveQuery>  );}

LiveQuery also accepts the typed table, where, orderBy, limit, and deps options from the hook. Raw SQL mode does not expose a params option; prefer typed predicates for dynamic values.

Multiple queries and derived views

useLiveQueries and LiveQueries create one controller per named dataset. Inspect a dataset’s own error when one panel fails; the aggregate state alone cannot explain which query failed.

TSX
import { useLiveQueries, useLiveSelection } from '@kalamdb/react';import { eq } from 'drizzle-orm';import { messages } from './schema'; export function RoomCounts() {  const live = useLiveQueries({    queries: {      main: { table: messages, where: (table) => eq(table.room, 'main') },      support: { table: messages, where: (table) => eq(table.room, 'support') },    },  });  const count = useLiveSelection(live, (context) => context.main.rows.length);  return (    <section>      <p>{live.main.state.loading ? 'Loading main…' : `${count} main messages`}</p>      {live.main.state.error && <p role="alert">Main: {live.main.state.error.message}</p>}      {live.support.state.error && <p role="alert">Support: {live.support.state.error.message}</p>}    </section>  );}

Use select on useLiveQuery to derive one view from its context. useMutationActions(client) provides mutations without opening a query; useMutationState exposes the mutation-state reducer for custom integrations. Neither adds an offline outbox.

Next steps