TanStack Query
Typed query and mutation options for every table.
createQueries(betterSupabase, db) gives every table option factories for TanStack
Query. The payload type of your select/include flows into useQuery,
useSuspenseQuery and queryClient.getQueryData.
const q = bs.queries; // or createQueries(betterSupabase, () => db)
useQuery(
q.customers.findMany({ select: ["id", "name"], where: { status: "active" } }),
);
useSuspenseQuery(q.customers.findById(id, { include: { notes: true } }));
useQuery(q.customers.paginate({ page, size: 20, count: "exact" }));
useInfiniteQuery(
q.customers.infinite({ size: 50, orderBy: { createdAt: "desc" } }),
); // cursor
useInfiniteQuery(q.customers.infinitePages({ size: 50 })); // offsetThe options are built on @tanstack/query-core, so they work with the
React, Vue, Solid, Svelte and Angular adapters alike.
Pass skipToken to disable a query until its input exists:
q.customers.findById(id ?? skipToken).
Infinite lists
infinite pages by cursor: each page
asks for the rows after the last one, so it skips the count and a deep page
costs the same as the first. The infinite query hook in the Supabase library
blocks pages with .range() and count: 'exact' instead, which counts the
whole table on every page and shifts rows when someone inserts while the user
scrolls. Keep infinitePages for lists that show a total.
Load the next page when a sentinel row scrolls into view:
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
export function CustomerFeed() {
const feed = useInfiniteQuery(
q.customers.infinite({ size: 50, orderBy: { createdAt: "desc" } }),
);
const sentinel = useRef<HTMLLIElement>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = feed;
useEffect(() => {
const node = sentinel.current;
if (!node || !hasNextPage) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting && !isFetchingNextPage) void fetchNextPage();
});
observer.observe(node);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<ul>
{feed.data?.pages.flatMap((page) =>
page.items.map((customer) => (
<li key={customer.id}>{customer.name}</li>
)),
)}
<li ref={sentinel} aria-hidden />
</ul>
);
}Pass { maxPages } as the second argument to keep a window of pages in
memory on a long feed: q.customers.infinite({ size: 50 }, { maxPages: 5 }).
infinitePages also sets getPreviousPageParam, so a list opened at
page: 4 can load page 3 with fetchPreviousPage().
Keys and invalidation
Keys are ['bs', table, operation, args]. Arguments are part of the key, so
different filters are different cache entries.
Every query also carries meta: { bsTables }, the tables it read, includes
and relation filters too. Invalidation matches on that meta, so a write to
notes refetches a customers.findMany that included notes. Invalidate by
hand with invalidateTables(queryClient, ['notes']). See
Caching. An index from table to cached queries,
kept current from the query cache's events, finds the matches, so a write to
a table no cached query read skips the cache scan.
Clearing on sign-out
Cached rows belong to the user who loaded them. clearOnUserChange removes
every ['bs', ...] query when the user signs out or another user signs in,
and returns the function that stops listening. With React,
<BetterSupabaseProvider queryClient={queryClient}> calls it for you; with
Vue, Solid, Svelte or Angular Query, call it once where you create the client:
import { clearOnUserChange } from "better-supabase/query";
const stop = clearOnUserChange(queryClient, bs.auth);It waits until the session has loaded before it records the first user, and leaves queries with other keys alone.
Mutations
const create = useMutation(q.customers.create({ select: ["id"] }));
const update = useMutation(q.customers.update());
update.mutate({ id, patch: { status: "active" } });Mutation options invalidate every query that read a changed table on
success, including tables changed by cascading deletes. If you pass your own
onSuccess, it replaces that behavior; call invalidateOnMutation(betterSupabase, queryClient) once instead to invalidate after every write the client makes.
It attaches the queryCache(client) cache adapter.
create, update and upsert on a table with a single-column primary key
also write the returned row into that row's findById entry when the write
selected every column, so a detail page opened next shows it without a fetch.
Optimistic updates
optimistic returns onMutate, onError and onSettled for a mutation: it
rewrites the cached lists before the request, puts them back when it fails,
and refetches them once it settles. Spread it next to the mutation option,
which keeps its own onSuccess invalidation:
import { optimistic } from "better-supabase/query";
const list = q.customers.findMany({ select: ["id", "name"] });
const rename = useMutation({
...q.customers.update({ select: ["id", "name"] }),
...optimistic.update(list),
});
rename.mutate({ id, patch: { name } });optimistic.create(list, (input) => row, { position: "start" }) adds a row
built from the input, optimistic.remove(list) drops the row whose id is the
mutation's variable, and optimistic(targets, (data, variables) => next)
rewrites any cached data. Pass an array to rewrite several lists.
Specs and RPCs
const spec = betterSupabase.spec.customers.findMany({
select: ["id", "name"],
limit: 20,
});
useQuery(q.$spec(spec));
useQuery(
q.$rpc(
"customer_stats",
{ organizationId },
{ tables: ["customers", "notes"] },
),
);
const archive = useMutation(q.$rpcMutation("archive_customer"));tables lists what the function reads, so writes to those tables refetch
it. $rpcMutation invalidates the tables declared with
betterSupabase.defineRpc(name, { invalidates }).
Errors
Failed queries reject with DbException (its error is the plain
DbError). Tell TanStack Query about it once to type error everywhere:
import type { DbException } from "better-supabase";
declare module "@tanstack/react-query" {
interface Register {
defaultError: DbException;
}
}Server prefetching
The same factories work on the server with the request-scoped db:
const { db } = await bs.context();
const q = createQueries(betterSupabase, db);
await queryClient.prefetchQuery(
q.customers.findMany({ select: ["id", "name"] }),
);q.$prefetch(queryClient, spec) prefetches a spec; dehydrate and hydrate the
client as usual. Keep live pages fresh with
useLiveQuery.
Last updated on