React hooks
ramose/react adapts the framework-neutral browser client to React’s external-store contract. It adds no cache or query semantics of its own.
New here? Start with React and offline UX.
import { RamoseProvider, useDb, useQuery, useReceipt, useSuspenseQuery, useSyncState,} from "ramose/react"QueryState and ReceiptView are exported here. Import the underlying ReceiptState from ramose/client.
RamoseProvider
Section titled “RamoseProvider”<RamoseProvider client={client}> <App /></RamoseProvider>Provides one already-created client. Remount with a new client when the signed-in principal changes. The provider does not silently transfer cached database state or queued receipts between principals.
const rootDb = useDb()Returns the authorized database handle from the nearest provider.
A context cannot carry its client’s catalog into each component’s types, so useDb() answers the runtime mutation namespace. Name it — useDb<DatabaseMutations<typeof AppSchema>>() — for the catalog’s exact operations.
useQuery
Section titled “useQuery”const tasks = useQuery( projectDb.query.from(Task).orderBy(Task.createdAt, "desc"),)Pass a database as an optional second argument to read a handle the surrounding tree does not provide.
Subscribes during commit and returns QueryState<A>:
type QueryState<A> = | { status: "pending" } | { status: "ready"; data: A } | { status: "stale"; data: A } | { status: "error"; error: Error }error is an Error: the framework-neutral client always reports one, so the hook narrows rather than handing you an unknown to widen back.
Snapshots are stable under concurrent rendering. Strict Mode does not duplicate activation or subscriptions. A structurally equivalent query reuses the same local observation; changed literals create the appropriate new observation.
pending has no complete local answer. stale preserves a complete cached answer while synchronization is behind. Terminal invalid or unauthorized queries use error; transient connectivity is represented by sync state and normally keeps prior data.
useSuspenseQuery
Section titled “useSuspenseQuery”<Suspense fallback={<Skeleton />}> <Board /></Suspense>
const Board = () => { const tasks = useSuspenseQuery(db.query.from(Task)) if (tasks.status === "pending") return <NothingCachedOffline /> return <List rows={tasks.data} />}Same arguments and QueryState<A> as useQuery; only the waiting differs. It suspends only while this query has no local answer and the session could still produce one, so a restored replica renders stale rather than replacing cached data with a fallback.
pending still reaches the component, and means something narrower here: no local answer and no session that can currently produce one — offline with nothing cached, or a client that is closed, unauthorized, or behind the deployed build. Render what an empty offline scope should look like; a fallback would never end.
A fence is not one of those: a cleared or reassigned scope withdraws its value and activates again, and the wait continues across that. The component rerenders when an answer arrives.
Errors are returned, not thrown. useQuery is unchanged; adopt Suspense per component.
Entity handles in React
Section titled “Entity handles in React”Entity results expose .data, .local, and .mutate. Components rerender when selected entity data or local pending metadata changes.
<TaskRow task={task.data} pending={task.local.pending} onDone={(done) => task.mutate.setDone({ done })}/>Use a row’s id as its React key. It is an opaque EntityId, stable for the same server, principal, and database, so it survives reloads and is safe in a route; another principal or database resolves it as denied. An entity created offline carries a ClientRef until its EntityId arrives, so a row keyed on id remounts once at that swap.
useReceipt
Section titled “useReceipt”const [receipt, setReceipt] = useState<Receipt | null>(null)const state = useReceipt(receipt)
<button onClick={() => setReceipt(db.mutate.createTask({ title }))}>Save</button>Observes one invocation, returning ReceiptView:
type ReceiptView = | { status: "idle" } | { status: "pending" } | { status: "queued" } | { status: "committed" } | { status: "rejected"; error: MutationRejectedError } | { status: "failed"; error: Error }Every member but idle is the client’s ReceiptState, unchanged: React adds no second mutation state machine.
idle is what a component reads before it holds a receipt — null and undefined both give it — so the hook can be called unconditionally. pending means a real invocation has not reached the outbox; failed means it never will.
Hold the receipt in state: rebuilding it during render would invoke once per render. Unmounting cancels nothing — a queued invocation is durable and proceeds without an observer.
Use this hook when output or rejection drives UI; use entity.local.pending for quiet row feedback.
Suspending on one invocation
Section titled “Suspending on one invocation”receipt.committed is a promise, so React 19 needs no adapter: use(receipt.committed) suspends until the server accepts, and throws its refusal to the nearest error boundary. Hold the receipt in state so the promise is stable across renders. React 18 has no use() — branch on useReceipt’s status instead.
useSyncState
Section titled “useSyncState”const sync = useSyncState(client)Returns the client connection and catch-up state independently from any query. Render stale query data and connectivity messaging together rather than replacing useful data with a spinner.
Server rendering
Section titled “Server rendering”Browser database handles depend on IndexedDB and belong in Client Components. Server Components may execute explicit server queries through the server API, but Ramose does not serialize a browser replica into HTML or maintain a hidden SSR result cache.