Skip to content
Guides contents

GuidesUI & local state

Local-only tables

Keep drafts, selections, and preferences in reactive client tables that do not sync to the server.

View as Markdown

Local-only tables hold drafts, selections, and preferences beside a synced client’s server data. They use the same query engine, but Rindle does not send them to the server. Server confirmations do not rewind their contents.

This guide extends the manual synced-app quickstart. A standalone browser store already keeps all its rows local. Local-only tables are temporary unless you enable persistence.

Add a client schema

Keep the generated SQL schema unchanged. Create a separate module for local tables:

// shared/client-schema.ts
import { extendSchema, string, table } from "@rindle/client";
import { schema } from "./schema.gen.ts";

export const draft = table("draft", { local: true })
  .columns({ id: string(), issueId: string(), body: string() })
  .primaryKey("id");

export const clientSchema = extendSchema(schema, { tables: [draft] });

Use this schema in the quickstart’s browser client:

// src/rindle-client.ts
import { createRindleClient } from "@rindle/optimistic";
import { mutators } from "../shared/app-def.ts";
import { clientSchema } from "../shared/client-schema.ts";

const currentUser = () => "demo";

export const app = await createRindleClient({
  schema: clientSchema,
  mutators,
  user: currentUser,
  api: { url: "", headers: () => ({ "x-user": currentUser() }) },
  onRejected: (envelope, reason) => window.alert(`${envelope.name}: ${reason}`),
});

The x-user header is the quickstart’s development identity. A deployed app must use its authenticated session. The API server and named queries continue to import the generated schema; extendSchema accepts only local tables.

Read and write a draft

This helper owns one live query. It inserts the first draft and edits later values:

// src/drafts.ts
import { app } from "./rindle-client.ts";

export function openDraft(issueId: string) {
  const view = app.store.query.draft.where.id(issueId).materialize();

  return {
    view,
    setBody(body: string) {
      const previous = view.data[0];
      return app.store.writeLocal((tx) => {
        const next = { id: issueId, issueId, body };
        if (previous) tx.edit("draft", previous, next);
        else tx.add("draft", next);
      });
    },
    close() { view.destroy(); },
  };
}

const draft = openDraft("issue-42");
const unsubscribe = draft.view.subscribe(() => {
  console.log(draft.view.data[0]?.body ?? "");
});
await draft.setBody("A reply in progress");

// When this consumer is finished:
unsubscribe();
draft.close();

view.data holds the current result. Use a subscription or a React hook to update a UI when that result changes. Destroy a materialized view when its owner is finished.

writeLocal resolves after the local write. It does not wait for IndexedDB storage or send an authoritative mutation.

Keep local state outside the mutation contract

  • writeLocal accepts only local tables. Use a named mutator for synced tables.
  • A mutator cannot read or write local tables. Its server execution and rebase must not depend on private browser state.
  • A named server query cannot reference a local table. Local queries can combine local rows with synced rows already available in the client.

Choose a lifetime

Declaration Without persistence With persistLocal
{ local: true } Memory for this client IndexedDB and coordination across tabs
{ local: "session" } Memory for this client Still ephemeral and per-tab

Use local: "session" for a transient selection. Use local: true for a draft that you may want to restore. Neither declaration sends its rows to the server.

See Persisting local tables for storage identity, restore behavior, and cleanup on logout.