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.
Project shape
Section titled “Project shape”notes-app/├── src/│ ├── shared/schema.ts│ ├── main.tsx│ └── NoteEditor.tsx├── package.json└── vite.config.tsNo worker.ts, no wrangler.jsonc. This is a client-only build.
-
The schema
Section titled “The schema”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 aFileRefmanifest. The actual bytes live in_plasma_blobs_local(an IDB store), keyed by SHA-256 hash. -
Client construction with
Section titled “Client construction with offline: true”offline: truesrc/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 fetchedclientGroupID: "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 anywaycreateRoot(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 —
readFileserves from local cache only - still enqueues outbox entries (so a future switch to online picks them up cleanly)
-
The note editor
Section titled “The note editor”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><textareavalue={draft}onChange={(e) => setDraft(e.target.value)}onBlur={() => editBody.mutate({id: noteId,body: draft,updatedAt: Date.now(),})}/><div><inputtype="file"accept="image/*"onChange={(e) => {const file = e.target.files?.[0]if (!file) returnattach.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>)}usePlasmaFilereturns aFileHandleunion. In offline mode the handle stays atstatus: "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. -
The App shell
Section titled “The App shell”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>)}
What offline gives you
Section titled “What offline gives you”- 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: falselater and the outbox flushes through the normal push path. Nothing is lost.
What offline restricts
Section titled “What offline restricts”resetLocalState()throws. No server to re-hydrate from means wiping is unrecoverable. If you really need to wipe, the correct call isindexedDB.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 aFileRefin your data doesn’t have local bytes (e.g. imported from a colleague’s export),readFilereturns{ status: "missing" }. There’s no fallback fetch in offline mode.
Deploying an offline shell
Section titled “Deploying an offline shell”For a Tauri app:
pnpm add -D @tauri-apps/clipnpm tauri initpnpm tauri devPoint 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).
What to read next
Section titled “What to read next”- Offline mode (Guide) — the full contract for
PlasmaClientOptions.offlineand per-tablechangeLogSuppressed - 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