Skip to content

Offline Notes with Images

This recipe builds a notes app for a scenario the Quick Start doesn’t cover: the client never talks to a plasma sync server. A Tauri / Electron / React Native / air-gapped Chrome app where every write is local and even the outbox never flushes.

Attachments still work — bytes stay in an IDB cache, indexed by SHA-256, rendered via the same usePlasmaFile hook.

notes-app/
├── src/
│ ├── shared/schema.ts
│ ├── main.tsx
│ └── NoteEditor.tsx
├── package.json
└── vite.config.ts

No worker.ts, no wrangler.jsonc. This is a client-only build.

  1. src/shared/schema.ts
    import {
    defineMutators, defineSchema, eq,
    file, id, int, table, text,
    } from "@sh1n4ps/plasma-core"
    export const notes = table("notes", {
    id: id(),
    title: text(),
    body: text(),
    attachment: file().nullable(),
    updatedAt: int(),
    })
    export const schema = defineSchema({ notes })
    export const SCHEMA_VERSION = "notes-offline-v1"
    export interface Ctx { userId: string }
    export const mutators = defineMutators<typeof schema, Ctx>()({
    createNote: async ({ db, args }) => {
    await db.insert(notes).values({
    id: args.id,
    title: args.title,
    body: "",
    updatedAt: args.updatedAt,
    })
    },
    editBody: async ({ db, args }) => {
    await db.update(notes)
    .set({ body: args.body, updatedAt: args.updatedAt })
    .where(eq(notes.id, args.id))
    },
    attach: async ({ db, args }) => {
    // args.attachment can be a File / Blob (from an <input type="file">)
    // or a pre-computed FileRef. desugarFileArgs handles the conversion.
    await db.update(notes)
    .set({ attachment: args.attachment, updatedAt: args.updatedAt })
    .where(eq(notes.id, args.id))
    },
    })

    The file() column stores a FileRef manifest. The actual bytes live in _plasma_blobs_local (an IDB store), keyed by SHA-256 hash.

  2. src/main.tsx
    import { createRoot } from "react-dom/client"
    import { createPlasmaClient } from "@sh1n4ps/plasma-client"
    import { PlasmaProvider } from "@sh1n4ps/plasma-react"
    import { App } from "./App"
    import { schema, mutators, SCHEMA_VERSION } from "./shared/schema"
    export const plasma = createPlasmaClient({
    schema,
    mutators,
    dbName: "notes",
    endpoint: "unused-in-offline-mode", // required by type, never fetched
    clientGroupID: "local-user",
    schemaVersion: SCHEMA_VERSION,
    getContext: async () => ({ userId: "local-user" }),
    offline: true, // ← the key knob
    })
    plasma.start() // no-op with offline: true — safe to call anyway
    createRoot(document.getElementById("root")!).render(
    <PlasmaProvider client={plasma}>
    <App />
    </PlasmaProvider>,
    )

    The client:

    • never opens the poll timer
    • never runs the WebSocket subscription
    • never triggers a blob upload — readFile serves from local cache only
    • still enqueues outbox entries (so a future switch to online picks them up cleanly)
  3. src/NoteEditor.tsx
    import { useEffect, useState } from "react"
    import { asc, eq } from "@sh1n4ps/plasma-core"
    import { useLiveQuery, useMutation, usePlasmaFile } from "@sh1n4ps/plasma-react"
    import { plasma } from "./main"
    import { mutators, notes } from "./shared/schema"
    export function NoteEditor({ noteId }: { noteId: string }) {
    const rows = useLiveQuery(
    () => plasma.db.select().from(notes).where(eq(notes.id, noteId)),
    [noteId],
    )
    const note = rows[0]
    const handle = usePlasmaFile(note?.attachment)
    const editBody = useMutation<typeof mutators, "editBody">("editBody")
    const attach = useMutation<typeof mutators, "attach">("attach")
    // Local-state input to avoid the 'one mutation per keystroke'
    // trap (see Beta #3 in the CHANGELOG's v1.1 queue).
    const [draft, setDraft] = useState(note?.body ?? "")
    useEffect(() => setDraft(note?.body ?? ""), [note?.body])
    if (!note) return <p>Not found</p>
    return (
    <div>
    <h1>{note.title}</h1>
    <textarea
    value={draft}
    onChange={(e) => setDraft(e.target.value)}
    onBlur={() => editBody.mutate({
    id: noteId,
    body: draft,
    updatedAt: Date.now(),
    })}
    />
    <div>
    <input
    type="file"
    accept="image/*"
    onChange={(e) => {
    const file = e.target.files?.[0]
    if (!file) return
    attach.mutate({
    id: noteId,
    attachment: file,
    updatedAt: Date.now(),
    })
    }}
    />
    {handle?.status === "pending" && <span>Loading…</span>}
    {handle?.status === "missing" && <span>Attachment missing</span>}
    {(handle?.status === "local" || handle?.status === "ready") && (
    <img src={handle.url} style={{ maxWidth: "100%" }} />
    )}
    </div>
    </div>
    )
    }

    usePlasmaFile returns a FileHandle union. In offline mode the handle stays at status: "local" because no upload happens — the Blob URL is served straight from the local IDB cache. This is deliberate: the “uploading” state would be a lie in offline mode.

  4. src/App.tsx
    import { useState } from "react"
    import { asc } from "@sh1n4ps/plasma-core"
    import { useLiveQuery, useMutation } from "@sh1n4ps/plasma-react"
    import { plasma } from "./main"
    import { NoteEditor } from "./NoteEditor"
    import { mutators, notes } from "./shared/schema"
    export function App() {
    const rows = useLiveQuery(
    () => plasma.db.select().from(notes).orderBy(asc(notes.updatedAt)),
    [],
    )
    const [selected, setSelected] = useState<string | null>(null)
    const createNote = useMutation<typeof mutators, "createNote">("createNote")
    return (
    <div style={{ display: "grid", gridTemplateColumns: "200px 1fr" }}>
    <aside>
    <button onClick={() => {
    const id = plasma.newId()
    createNote.mutate({ id, title: "Untitled", updatedAt: Date.now() })
    setSelected(id)
    }}>+ New</button>
    <ul>
    {rows.map((n) => (
    <li key={n.id}>
    <button onClick={() => setSelected(n.id)}>{n.title}</button>
    </li>
    ))}
    </ul>
    </aside>
    <main>
    {selected ? <NoteEditor noteId={selected} /> : <p>Select a note</p>}
    </main>
    </div>
    )
    }
  • Zero network dependency. Works in an airplane, on a Tauri desktop with no server, in a React Native shell that talks to its own local sync layer.
  • Full plasma DSL. Same schema, same mutators, same live queries, same file() column. Everything from the online path works — the sync loop is just paused.
  • Reversible. Flip offline: false later and the outbox flushes through the normal push path. Nothing is lost.
  • resetLocalState() throws. No server to re-hydrate from means wiping is unrecoverable. If you really need to wipe, the correct call is indexedDB.deleteDatabase("notes") and reconstruct.
  • Multi-tab still splits by clientID. Two tabs of the same offline user each have their own outbox. When the client goes online later, both tabs’ outboxes push through their own client identity slots. Fine, but be aware.
  • readFile(ref) never fetches from /sync/blob/*. If a FileRef in your data doesn’t have local bytes (e.g. imported from a colleague’s export), readFile returns { status: "missing" }. There’s no fallback fetch in offline mode.

For a Tauri app:

Terminal window
pnpm add -D @tauri-apps/cli
pnpm tauri init
pnpm tauri dev

Point Tauri at your Vite build. The whole plasma runtime is inside the WebView; nothing goes over the network.

For a React Native app, use expo or plain react-native with a compatible IndexedDB polyfill (e.g. /react-native-quick-sqlite with @sh1n4ps/plasma-client-sqlite).

  • Offline mode (Guide) — the full contract for PlasmaClientOptions.offline and per-table changeLogSuppressed
  • Files and blobs — R2 wiring for when you flip to online later
  • Todo app — a full sync backend if you want the “eventually online” version