Data hooks
Generated list and record hooks per table for React, Vue, Svelte and Solid, with optimistic writes, on a memory cache, TanStack Query, TanStack DB or a database on the device.
With gen.hooks set, better-supabase gen writes hooks.ts next to the
main module: a list hook and a record hook for every table, typed by your
definition. It defaults to "react" when the config names the next,
react or expo adapter; set "vue", "svelte" or "solid" for another
framework, or false to write none.
export default defineConfig({
gen: { output: "src/lib/supabase/generated.ts", hooks: "react" },
});export const { useList: useCustomers, useOne: useCustomer } = defineDataHooks(
"customers",
(db: AppDb) => db.customers,
);A table whose singular and plural names match gets use<Name>List and
use<Name>.
Reading
Each hook reads bsb.db from the provider, so it acts as the signed-in user
and in the active organization. useCustomers(args) takes the same
arguments as db.customers.list(args), and useCustomer(id) the same as
db.customers.get(id):
"use client";
import { useCustomers } from "@/lib/supabase/hooks";
export function CustomerList() {
const customers = useCustomers({ orderBy: { name: "asc" }, limit: 50 });
if (customers.isPending) {
return <CustomerListSkeleton />;
}
if (customers.error) {
return <InlineError onRetry={customers.refetch} />;
}
return <CustomerTable rows={customers.data ?? []} />;
}| Field | What it holds |
|---|---|
data | The rows or the row, undefined until the first read |
error | The read's DbError |
isPending | True until the first answer arrives |
status | idle, loading, success or error |
Two components that read the same table with the same arguments share one read. A change of user, organization or arguments reads again.
where also takes a filter tree, the shape
list queries and saved views produce. The
hook checks it against the table's filterable columns and returns kind
invalid_request for an unknown column or operator:
const customers = useCustomers({
where: {
id: "root",
type: "group",
combinator: "or",
rules: [
{
id: "a",
type: "rule",
path: ["status"],
operator: "eq",
value: "active",
},
{
id: "b",
type: "rule",
path: ["name"],
operator: "ilike",
value: "%acme%",
},
],
},
});Writing
create(input), update(id, patch) and delete(id) call the repository
and update every cached list and record of that table at once. An error
rolls the change back. Each one resolves to the row and rejects with a
DbException, so a form can await it:
const { create } = useCustomers();
await create({ name: "Acme" });TanStack Query
By default the hooks keep their reads in memory. To keep them in TanStack
Query, so devtools, staleTime and invalidateQueries see them, pass
queryEngine to createClient. The reads live under
["bsb", <table>, <organization>, <verb>, <args>]:
import { createClient } from "better-supabase/client";
import { queryEngine } from "better-supabase/query";
export const bsb = createClient(betterSupabase, {
engine: queryEngine(queryClient),
});queryClient.invalidateQueries({ queryKey: ["bsb", "customers"] });@tanstack/query-core is an optional peer: install it only when you use the
engine.
Engines
The engine decides where the hooks keep their reads and where bsb.db runs.
Every engine runs the same generated hooks, so moving a screen offline is a
change to createClient, not to the components.
| Engine | Entry | Reads kept in | bsb.db runs on |
|---|---|---|---|
memoryEngine(), the default | better-supabase/client | memory | PostgREST |
queryEngine(queryClient) | better-supabase/query | TanStack Query | PostgREST |
tanstackDbEngine(options) | better-supabase/tanstack-db | TanStack Query, refreshed by TanStack DB collections | PostgREST |
powersyncEngine(db) | better-supabase/powersync | TanStack Query or memory | the PowerSync database |
sqliteEngine(database) | better-supabase/sqlite | TanStack Query or memory | Expo SQLite or Lite |
The device engines take local, the tables the device database holds, and
send the rest to PostgREST. See PowerSync,
SQLite and TanStack DB.
Live reads
Pass live to memoryEngine or queryEngine to read a hook again when its
table changes in the database. realtimeChanges(betterSupabase, supabase)
from better-supabase/realtime follows Supabase Realtime for the tables in
realtime.tables, or the ones you pass as tables:
import { queryEngine } from "better-supabase/query";
import { realtimeChanges } from "better-supabase/realtime";
export const bsb = createClient(betterSupabase, {
engine: queryEngine(queryClient, {
live: realtimeChanges(betterSupabase, supabase, {
tables: ["customers", "notes"],
}),
}),
});A hook named after something other than a table names its tables in the
engine's tables, such as tables: { "customer-pages": ["customers"] }. The
device engines follow their own database's changes and need no live.
Your own hooks
defineDataHooks(name, select) from better-supabase/react, /vue,
/svelte or /solid makes the same pair for any source with list, get,
create, update or delete, such as a filtered view of a table. name
is the cache key, so two hooks with one name share reads and writes.
Last updated on