# Local-only tables

Give drafts, selections, and view preferences the same reactive query machinery as synced rows — declare a `{ local: true }` table, write it with `store.writeLocal`, and compose it with synced data locally. It never crosses the wire, and an optimistic rebase never touches it.

Not every row belongs to the server. Draft text, the current selection, expanded/collapsed state, a
scratch thread — this is **client-authoritative** state that wants the same reactive query engine as
your synced rows but should stay private to the device and survive server confirmations unchanged.
Declare those tables `{ local: true }` and they live in the same store, queryable and reactive, but
they never sync.

## Declare it, and keep the generated schema generated

A local table is an ordinary [typed table def](/docs/schema) with one option. Because your synced
schema is [generated from SQL](/docs/schema) and shouldn't be hand-edited, put local tables in a small
module and fold them in with `extendSchema`:

```ts
// schema.local.ts
import { extendSchema, string, table } from "@rindle/client";
import { schema as generatedSchema } from "./schema.gen.ts";   // generated — never hand-edited

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

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

Hand `clientSchema` to `createRindleClient` in the browser. `extendSchema` accepts **only** local
tables, so your API server and named-query registry keep using the plain generated `schema` — real
synced tables stay SQL-first and can't drift.

## Write it with `store.writeLocal`

Local tables are written directly, outside the optimistic mutation stack:

```ts
await app.store.writeLocal((tx) => {
  tx.add("draft", { id: "compose", issueId: "42", body: "half-written reply" });
});
// later — the keyed tx exposes add / edit / remove:
await app.store.writeLocal((tx) => tx.edit("draft", prev, { ...prev, body: text }));
```

## Read it — and compose it with synced rows

`store.query` materializes a local table exactly like a synced one, and can join it against synced
rows **locally**:

```ts
const drafts = store.query.draft.where.issueId("42").materialize();
drafts.data; // reactive, updates on every writeLocal
```

Two guardrails keep private and synced state apart:

- **`writeLocal` accepts only local tables** — writing a synced table there is rejected (use a
  [mutator](/docs/mutators)).
- **Mutators can't read or write local tables.** A mutator replays from `(name, args)` on the server
  and again on every rebase, so it must not depend on private browser rows. Likewise, a **named
  server query** can't reference a local table — your remote contract never accidentally depends on
  UI state.

That last property is the point: because an optimistic rebase rewinds and replays *synced* state
only, your draft text and selection sit completely still while the server confirms writes around
them.

## Durable or ephemeral

`{ local: true }` is the durable-capable variant: when the client turns on persistence it survives
reloads and stays coherent across tabs (see [Persisting local tables](/docs/persisting-local-tables)).
For state that must stay per-tab and vanish on reload even with persistence on — a scratch thread,
transient selection — declare it `{ local: "session" }` instead. Both are equally invisible to the
server; they differ only in durability.

## In the wild — Strut

[Strut](https://github.com/tantaman/strut) is a slide-deck editor built on Rindle. Its "✨ Chat"
advisor thread is a local table — private per device, never synced to co-editors, and deliberately
**ephemeral** (`{ local: "session" }`), because an advisor bounce doesn't need durable history:

```ts
// src/rindle/localSchema.ts
export const chatMessage = table("chat_message", { local: "session" })
  .columns({
    id: string(),
    deck_id: string(),
    role: string<"user" | "assistant">(),
    content: string(),  // grows per token for a streaming assistant turn
    status: string<"streaming" | "done" | "error">(),
    note: string(),
    created: number(),
  })
  .primaryKey("id");

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

*[`src/rindle/localSchema.ts` L37–66](https://github.com/tantaman/strut/blob/cac07f41d5b13233c26d89bf25ac55745295d4ac/src/rindle/localSchema.ts#L37-L66) · Strut*

The panel streams an assistant turn straight into that table with `store.writeLocal` — a keyed
`add` for the two initial rows, then a keyed `edit` per token — and reads it back with a plain
`store.query`, no server round-trip:

```ts
// src/editor/aiChat.ts
await store.writeLocal((tx) => {
  tx.add("chat_message", userRow);
  tx.add("chat_message", assistantRow);
});
// … then per streamed token:
store.writeLocal((tx) => tx.edit("chat_message", prev, next));

// the live thread, materialized locally:
store.query.chat_message.where.deck_id(deckId).orderBy("created", "asc").materialize();
```

*[`src/editor/aiChat.ts` L223–237](https://github.com/tantaman/strut/blob/cac07f41d5b13233c26d89bf25ac55745295d4ac/src/editor/aiChat.ts#L223-L237) (write) · [L776–795](https://github.com/tantaman/strut/blob/cac07f41d5b13233c26d89bf25ac55745295d4ac/src/editor/aiChat.ts#L776-L795) (read) · Strut*

Strut's other local table, `deck_pref`, is the **durable** variant — see the next recipe.

## See also

- [Persisting local tables](/docs/persisting-local-tables) — make a `{ local: true }` table survive
  reloads and stay coherent across tabs with one client option.
- [The browser client](/docs/client#local-only-tables-drafts-selections-prefs) — the local-table API
  in full.
- [Schema & migrations](/docs/schema#adding-local-only-client-tables-to-a-generated-schema) — where
  local tables sit next to your generated schema.

---

[View this page on Rindle](https://rindle.sh/docs/local-only-tables)
