createRindleClient is Rindle’s integrated optimistic browser client. It
connects a local wasm engine to your API server and data tier. It manages named
query subscriptions, predicts writes, and reconciles them with server results.
This is one browser option. For local data, remote results without wasm, or normalized sync without prediction, choose a browser client. Using React is a separate choice; this client also works with ordinary DOM code and other UI frameworks.
Prerequisites
You need a running Rindle data tier and an
API server with your schema, named queries, and mutators.
create-rindle generates this setup.
Manual synced-app setup shows each piece.
This page focuses on the browser. Its examples use the manual quickstart’s
SQL-generated issue table, including these columns:
| Column | Type |
|---|---|
id, title, status |
string |
createdAt, updatedAt |
number |
Keep the generated schema authoritative. The examples import it from
shared/schema.gen.ts; they do not redefine the server’s tables in TypeScript.
Install the browser packages and the validator used below:
pnpm add @rindle/client @rindle/optimistic zod
Your browser build must load WebAssembly assets. createRindleClient initializes
wasm for you. For a custom asset URL, call initWasm first as described in the
wasm guide.
Define the shared query and writes
A named query supplies the server identity for a subscription. A mutator is a named write that the browser predicts and the server executes with authority. Both belong in shared modules that your browser and API server can import.
For this example, put the following in shared/client-example.ts:
import { defineMutators, defineQuery, newQueryBuilder } from "@rindle/client";
import type { MutationGen } from "@rindle/client";
import type { ClientRegistry } from "@rindle/optimistic";
import { z } from "zod";
import { schema } from "./schema.gen.ts";
export { schema };
const q = newQueryBuilder(schema);
export const issuesPageQuery = defineQuery(
"issuesPage",
z.object({ limit: z.number().int().min(1).max(100) }).parse,
({ limit }) => q.issue.orderBy("createdAt", "desc").limit(limit),
);
const { shared } = defineMutators(schema);
export const mutators = {
setStatus: shared(
z.object({ id: z.string(), status: z.string(), updatedAt: z.number() }),
function* (tx, args): MutationGen {
yield tx.update("issue", args);
},
),
setTitle: shared(
z.object({ id: z.string(), title: z.string(), updatedAt: z.number() }),
function* (tx, args): MutationGen {
yield tx.update("issue", args);
},
),
} satisfies ClientRegistry;
Register this query and these mutators with your API server. Defining them in the browser alone does not authorize or register them on the server. Add application access rules there; the sample bodies only demonstrate updates.
A shared mutator is a generator that yields logical operations. Its body runs again during rebase, so it must be deterministic. Supply clocks, random IDs, and other external values as arguments. An update to a missing row is a no-op. See Isomorphic mutators for reads, permissions, and the full operation vocabulary.
Create one client for the browser session
Put this in src/rindle-client.ts. This example assumes your API server accepts
a bearer token and derives the authenticated user from it:
import { createRindleClient } from "@rindle/optimistic";
import { mutators, schema } from "../shared/client-example.ts";
export interface Session {
userID: string;
accessToken: string;
}
export async function openClient(getSession: () => Session) {
return createRindleClient({
schema,
mutators,
user: () => getSession().userID,
api: {
url: "",
headers: () => ({
authorization: `Bearer ${getSession().accessToken}`,
}),
},
onRejected: (envelope, reason) => {
console.error(`Mutation ${envelope.name} rejected: ${reason}`);
},
onMutationError: (error, attempt) => {
console.error(`Mutation request failed on attempt ${attempt}`, error);
},
});
}
export type AppClient = Awaited<ReturnType<typeof openClient>>;
Call openClient from browser startup with your application’s session reader.
Reuse the returned client across screens. If your application uses same-origin
session cookies, adapt the header configuration to that authentication setup.
The local user option affects predictions; it does not authenticate a request.
The server supplies its own verified ctx.user.
With api.url: "", the client calls the same-origin routes
/api/rindle/query and /api/rindle/mutate. Set another base URL or api.routes
when your server exposes them elsewhere. api.headers can be an object or a
function, including an asynchronous function. It runs for each request.
Normally, omit daemon. The first query lease returns its public WebSocket
endpoint and affinity ticket. The client opens that connection and handles
subsequent recovery and supported endpoint changes. Advanced integrations can
supply daemon: { wsUrl } or a fixed { transport }.
The result includes store, backend, mutate, ensure, flushFolds,
clientID, and close. Creating it initializes the client; it does not wait for
all of your application queries to load.
For SSR, create the browser client only after entering the browser. Use the separate server-rendering setup for server reads and hydration.
Reads: live views
A named query opens a server subscription. Its local view can appear before the first server answer, using rows already present in the browser.
Without React
The following DOM integration owns one client and one query. It accepts an existing element and your session reader, then returns a cleanup function:
import { issuesPageQuery } from "../shared/client-example.ts";
import { openClient } from "./rindle-client.ts";
import type { Session } from "./rindle-client.ts";
export async function mountIssues(element: HTMLElement, getSession: () => Session) {
const app = await openClient(getSession);
const view = app.store.materialize(issuesPageQuery({ limit: 50 }));
const unsubscribe = view.subscribe((rows) => {
const list = document.createElement("ul");
for (const row of rows) {
const item = document.createElement("li");
const button = document.createElement("button");
button.textContent = `${row.title} — ${row.status}`;
button.onclick = () => {
app.mutate.setStatus({ id: row.id, status: "done", updatedAt: Date.now() });
};
item.append(button);
list.append(item);
}
const status = view.resultType === "unknown" ? "Loading…" : "Issues";
element.replaceChildren(document.createTextNode(status), list);
});
return () => {
unsubscribe();
view.destroy();
app.close();
element.replaceChildren();
};
}
subscribe fires immediately and on data or readiness changes. view.data is
the current result. Keep the view alive while its owner needs that result.
Removing the listener alone does not release the query.
With React
Install @rindle/react in your React application. Pass the existing client store
to Rindle; the hooks manage query views and subscriptions:
import { Rindle, useQuery, useQueryStatus } from "@rindle/react";
import { issuesPageQuery } from "../shared/client-example.ts";
import type { AppClient } from "./rindle-client.ts";
export function IssueApp({ app }: { app: AppClient }) {
return <Rindle store={app.store}><IssueList /></Rindle>;
}
function IssueList() {
const query = issuesPageQuery({ limit: 50 });
const rows = useQuery(query);
const status = useQueryStatus(query);
return (
<>
{status === "unknown" && <p>Loading…</p>}
<ul>{rows.map((row) => <li key={row.id}>{row.title}</li>)}</ul>
</>
);
}
Mount IssueApp through your existing React root. Dispose that root before
closing its client. Unchanged result rows retain object identity, which helps
memoized components avoid work.
Separate queries are useful for independent screens or widgets. When a parent and its descendants share a related data tree, fragments can compose their requirements into one named root query.
React retains an unused query for two seconds by default. This can reuse rows
and subscriptions during short navigation gaps. For transient queries such as
search prefixes, pass { releaseDelayMs: 0 } to useQuery and any corresponding
useQueryStatus call. A shared reader’s unexpired retention window can still keep
that query alive. See Search and typeahead.
Loading and pending writes
For a named query, view.resultType starts as unknown and becomes complete
after the server answers. An empty result can be complete. An optimistic write
does not turn a complete result back into a loading result. error is reserved;
the current client does not use it as a general query-error channel.
Read view.resultType in the view’s subscription, as above, or use
useQueryStatus. The Store also exposes an additive subscribeResultType
observer for integrations. Do not install backend.onResultType yourself: the
store already owns that callback.
The backend separately exposes pendingTables() and query-level pending hooks
for custom integrations. These describe unconfirmed writes touching tables;
they are not a query’s initial-loading state or proof that a particular row is
pending. See Devtools for inspecting individual mutations.
For navigation that must wait for an authoritative query result, use
await app.ensure(namedQuery). See Preloads for readiness and
retention options.
Writes: optimistic, rebased
app.mutate.setStatus(args) runs the shared body against local rows
synchronously. Affected views update before the call returns. The return value
is a mutation ID, not an acknowledgment promise.
The client queues the mutation’s name and arguments. Your API server runs the registered body in an authoritative transaction. As confirmed changes arrive, the browser removes settled predictions and reapplies pending bodies to the confirmed data. A prediction can change during this rebase if its inputs differ from the server’s data.
This behavior also supports read-dependent writes. A mutator that reads a row and increments its value reads again during rebase. A missing local row can produce no visible prediction even if the server later performs the write.
Raw store.write() is not the write API for synced tables on this client. Use
named mutators for those tables and writeLocal for explicitly local tables.
Folded writes: high-frequency drags
For a setter that receives frequent replacement values, .folded() updates the
local prediction on every call while delaying the server write. Calls with the
same mutator name and key replace one pending set of arguments.
Using setTitle from the shared module above:
import type { AppClient } from "./rindle-client.ts";
export function saveTitle(app: AppClient, id: string, title: string) {
return app.mutate.setTitle.folded(
{ key: id, debounceMs: 120, maxWaitMs: 1000 },
{ id, title, updatedAt: Date.now() },
);
}
The write flushes after 120 milliseconds without another same-key call. During sustained input, a call at least one second into the window also flushes it. The threshold is checked on calls, not by a separate deadline timer. A long interaction can therefore produce several server writes.
The returned handle has flush() and a mid promise. flush() ends the current
fold window. mid resolves when the client assigns the flushed write its wire
ID; it does not mean the server accepted that write.
A folded mutator must be absorbing: applying only the last arguments must produce the same state as applying all of them. Setters can meet this contract; increments do not. The folded path rejects mutators that read state. See Folded mutations for overlapping writes and flush rules.
app.flushFolds() flushes all open folds. The client also attempts this on page
exit. Neither mechanism guarantees delivery before a page closes or crashes.
Rejections and network failures
A final rejection removes the refused prediction during reconciliation.
onRejected(envelope, reason) supplies the reason for your UI. An authoritative
transaction failure does not commit the rejected application changes; mutation
progress can still advance so the client can settle that mutation.
A failed HTTP mutation request is different. onMutationError(error, attempt)
reports an attempt without a final verdict. The client retries with backoff, and
later queued mutations wait behind that batch. Pending predictions remain while
delivery is unresolved.
The queue lives in memory. It is not a durable offline queue, and a reload can lose pending writes. See Rejected writes for the application-facing error pattern.
Local-only tables: drafts, selections, prefs
To keep browser-owned UI data alongside synced rows, extend the generated schema
with tables marked local: true:
// shared/schema.local.ts
import { extendSchema, string, table } from "@rindle/client";
import { schema as generatedSchema } from "./schema.gen.ts";
const draft = table("draft", { local: true })
.columns({ id: string(), body: string() })
.primaryKey("id");
export const clientSchema = extendSchema(generatedSchema, { tables: [draft] });
Pass clientSchema as the browser client’s schema. Keep the generated synced
schema in the API server and named-query registry. The client’s store.writeLocal
can write these local tables; ordinary mutators cannot read or write them.
Local queries can combine local tables with available synced rows. Rebase does not rewind local tables. They are not sent to the server, so an unrelated server confirmation or rejection does not overwrite a draft.
By default, local rows also live only in memory. The persistLocal client option
restores eligible local tables from IndexedDB before construction resolves. It
can coordinate those local tables across tabs. It does not persist synced rows
or pending mutations. See Local-only tables and
Persisting local tables for write examples, user
identity, session-only tables, and logout cleanup.
Local query resolution
A bare app.store.query.issue.where.id(id).one().materialize() opens a local view
without a server subscription. It reads rows currently retained in the browser.
It can be useful when another named query already provides the relevant data.
It cannot prove server absence or a complete table-wide result. Releasing named queries can remove rows that no remaining subscription retains. An independent local view does not keep that remote data subscribed. Use a named query when you need the server to supply and maintain its result.
Close the client
Unmount framework consumers and destroy manually created views before calling
app.close(). The method releases the client’s connections, query preloads,
timers, and local-persistence attachment. Do not reuse a closed client.
close() attempts to flush open folds. It does not wait for server confirmation
and is not a save operation. Recreate the client when switching its authenticated
user, and follow the persistence guide’s explicit logout cleanup if local data
must be deleted.
The default clientID separates mutation sequences by origin, tab, and client
instance. Keep it unless your integration deliberately manages that identity.
Persisting an identity does not persist its queued writes.
Next steps
- Isomorphic mutators — define deterministic reads and writes with server authority.
- Fragments — compose a related UI tree into one named query.
- Preloads — prepare data before navigation.
- Server rendering — seed a page and hand off to the browser client.
- Devtools — inspect queries, predictions, and confirmed changes.
- API server — register and authorize the shared contract.