Skip to content

Reading Tables from an App

Use the useTable hook to read and live-subscribe to a table from a custom Bifrost app.

This guide covers reading and writing tables from a custom app (the React/TSX surface that runs inside Bifrost). For workflows, see the Tables Module SDK reference — the access model is the same; only the surface differs.

useTable is a React hook that loads a table snapshot via REST and then keeps it in sync via a websocket subscription. Insert, update, and delete events apply to the local state automatically; your component just renders rows.

The same row-level policies that gate REST also gate the websocket fan-out. A row the user can’t read is never in the snapshot and is never delivered as an event.

import { useTable } from "bifrost";
export function TicketList() {
const { rows, loading, error } = useTable("tickets");
if (loading) return <div>Loading…</div>;
if (error) return <div>Failed to load: {error.message}</div>;
return (
<ul>
{rows.map((r) => (
<li key={r.id}>
{String(r.title)}{String(r.status)}
</li>
))}
</ul>
);
}

rows is an array of flat objects: JSONB fields from the row’s data are spread to the top level alongside column-mapped fields (id, created_by, updated_by, created_at, updated_at, table_id). This matches the shape websocket events deliver, so live updates merge cleanly with the snapshot.

useTable accepts a where filter in the same dict-shorthand DSL the Python SDK uses — one filter syntax across both surfaces.

// Equality
useTable("notes", { where: { client_id: clientId } });
// Comparison operators
useTable("invoices", { where: { amount: { gte: 100, lt: 1000 } } });
// Substring (case-insensitive)
useTable("clients", { where: { name: { contains: "acme" } } });
// IN
useTable("tickets", { where: { status: { in: ["open", "pending"] } } });
// Null check
useTable("tasks", { where: { deleted_at: { is_null: true } } });
// Multiple conditions combine with AND
useTable("notes", {
where: {
client_id: clientId,
pinned: true,
},
});

Operators: eq, neq / ne, gt, gte, lt, lte, in, is_null, has_key, contains, starts_with, ends_with. The full list with examples is in the Tables module reference’s Filter Operators section.

The where clause runs on top of the policy filter, not in place of it. Users still only see rows their policies grant.

Pass order_by / order_dir for sort, limit / offset for one-shot pagination:

const { rows } = useTable("tickets", {
where: { status: "open" },
order_by: "created_at",
order_dir: "desc",
limit: 50,
});

The server caps limit at 1000. For tables that may grow past that, use useTablePaged instead — it loops with offset and skip_count: true after the first page.

import { useTablePaged } from "bifrost";
export function ContactsList() {
const { rows, loadMore, hasMore, loading } = useTablePaged("contacts", {
pageSize: 100,
});
return (
<>
<ul>{rows.map((r) => <li key={r.id}>{String(r.name)}</li>)}</ul>
{hasMore && <button onClick={loadMore} disabled={loading}>Load more</button>}
</>
);
}

Live updates apply to whatever’s been loaded.

When called inside an org-scoped app (one whose app.organization_id is set), useTable and tables.* automatically scope to the app’s org — same convention as org-scoped workflows always running as their org regardless of who triggered them. Apps marked global fall back to caller’s-org behavior.

You don’t need to thread an org id through every call. To override the default (provider admins targeting another org), pass scope explicitly:

useTable("notes", { scope: "11111111-2222-3333-4444-555555555555" });

When a row that was previously hidden becomes visible — for example, an admin changes assignee so the policy now matches — the user receives an insert event and the row appears in the UI without a refetch. Conversely, if a row is mutated out of visibility, the user receives a delete event and it disappears.

This is “visibility-gain via reassignment,” and it’s why useTable doesn’t need a refresh button.

If the server rejects the websocket subscription — table not found in scope, policies missing, access denied — useTable surfaces the rejection through error. It does not silently leave the snapshot showing while live updates never arrive. Render error.message and you’ll see the cause.

For mutations, use the imperative tables client from the same SDK. Same policies, same scope behavior as the hook.

import { tables } from "bifrost";
await tables.insert("tickets", { title: "New ticket", status: "open" });
await tables.update("tickets", rowId, { status: "closed" });
await tables.delete("tickets", rowId);

Operations throw distinct errors so you can react appropriately:

import { tables, TableAccessDeniedError, TableNotFoundError } from "bifrost";
try {
await tables.insert("tickets", data);
} catch (e) {
if (e instanceof TableAccessDeniedError) {
// policy denied — user shouldn't see/do this
} else if (e instanceof TableNotFoundError) {
// the table doesn't exist in this scope
} else {
throw e;
}
}

For row-level reads/updates (tables.get, tables.update, tables.delete with a single id), a missing target returns null / false rather than throwing — that case is “row may not exist,” which is normal. Access-denied still throws TableAccessDeniedError.

The platform also writes a policy.deny row to the audit log automatically when a 403 fires, so you don’t need to duplicate that signal.

Use caseSurface
Live-updating UI in a custom appuseTable hook
Paginated list in a custom appuseTablePaged hook
One-shot read in an app (no live updates)tables.query() from the imperative SDK
Read or write from a workflowsdk.tables.get("name") then .query() / .insert() etc.
Server-to-server batch jobsWorkflow SDK (runs as the workflow’s principal)

App requests run as the logged-in user, so all policies evaluate against that user’s user_id, organization_id, and roles. Workflow requests run as the workflow’s execution context — typically a service principal — so own-row patterns based on { user: user_id } won’t work the way you’d expect from a workflow. Design your policies with both surfaces in mind.