React
Read Minnow live-query snapshots with React's concurrent external-store API.
@minnowdb/react is a small adapter over the framework-neutral store implemented by every typed
live query and keyed live query.
npm install @minnowdb/reactCreate the query once, then pass it to useLiveQuery:
import { useEffect, useMemo } from "react";
import { useLiveQuery } from "@minnowdb/react";
function OpenOrders() {
const query = useMemo(
() =>
db
.selectFrom("orders")
.select(["order_id", "customer_id", "total"])
.where("status", "=", "open")
.$call(live),
[],
);
useEffect(() => () => query.close(), [query]);
const snapshot = useLiveQuery(query);
if (snapshot.status === "loading") return <p>Loading…</p>;
if (snapshot.status === "error") return <p>Could not refresh this view.</p>;
return (
<ul>
{snapshot.rows.map((row) => (
<li key={row.order_id}>{row.customer_id}</li>
))}
</ul>
);
}The hook uses React's concurrent-safe external-store primitive and the same stable snapshot for server rendering. Observation starts with the first subscriber and stops after the last cleanup; duplicate subscriptions are independent leases. Keep the live manager and query stable across renders so a render does not create a new database subscription.
Suspense and stale-while-revalidate
For a Suspense boundary, use the dedicated hook:
import { Suspense } from "react";
import { useSuspenseLiveQuery } from "@minnowdb/react";
function OpenOrders() {
const snapshot = useSuspenseLiveQuery(query);
if (snapshot.status === "error") return <p>Could not refresh this view.</p>;
return <OrderList rows={snapshot.rows} />;
}
function Page() {
return (
<Suspense fallback={<p>Loading…</p>}>
<OpenOrders />
</Suspense>
);
}It calls the store's refresh() once per cold load and throws that shared promise until the query
settles. Its return type excludes the loading snapshot. The equivalent lower-level call is
useLiveQuery(query, { suspense: true }); a query used this way must implement refresh().
When switching between stable query objects, keep the last settled snapshot on screen while the new one loads:
const snapshot = useLiveQuery(query, { staleWhileRevalidate: true });The stale value is local to that hook instance and disappears as soon as the new query publishes
ready or error. The options compose, so { suspense: true, staleWhileRevalidate: true } keeps
settled UI during a switch but still suspends on the first-ever load.
useLiveQuery is generic over the query snapshot, so keyed changes require no second hook:
const snapshot = useLiveQuery(orderChanges);
if (snapshot.status === "ready") applyChanges(snapshot.changes);Rendering only what changed
A live query keeps the object of every row that did not change between snapshots, and keeps the
rows array itself when nothing changed. A list whose items are memoized on the row object
re-renders only the rows that differ:
const OrderRow = memo(function OrderRow({ row }: { row: typeof query.$inferRow }) {
return <li>{row.customer_id}</li>;
});
function OpenOrders() {
const snapshot = useLiveQuery(query);
if (snapshot.status !== "ready") return null;
return (
<ul>
{snapshot.rows.map((row) => (
<OrderRow key={row.order_id} row={row} />
))}
</ul>
);
}For a component that needs one thing from a snapshot — a count, one row by key, the ids of a
list — useLiveSelector re-renders only when that thing changes:
import { useLiveSelector } from "@minnowdb/react";
function OpenOrderCount() {
const count = useLiveSelector(query, {
select: (snapshot) => (snapshot.status === "ready" ? snapshot.rows.length : null),
});
return <span>{count ?? "…"}</span>;
}The selection is compared with Object.is by default. Pass isEqual when select builds a new
value each time, such as an array of ids, so an equal selection keeps the previous value and the
component stays put. The selector runs synchronously against the snapshot and may be an inline
function; a new function re-selects, and isEqual decides whether the result counts as a change.
Commits that change a table without changing a query's rows never reach the component at all: the engine compares before it invalidates, so neither hook sees a new snapshot.
See Live queries for query construction, errors, keyed changes, windows, and teardown.