Skip to content
Reference contents

ReferenceClients & servers

Optimistic browser client

Create a synced browser client, subscribe to named queries, and manage optimistic writes and client resources.

View as Markdown

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.